Initial commit of project management
This commit is contained in:
commit
f3531aa48e
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.pyc
|
||||||
|
*.egg-info
|
||||||
|
*.swp
|
||||||
|
tags
|
||||||
|
node_modules
|
||||||
|
project_management/public/node_modules
|
||||||
|
project_management/public/public
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
21
license.txt
Normal file
21
license.txt
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) [year] [fullname]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
11
package.json
Normal file
11
package.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "project_management",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Project Management UI",
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "cd pm_app && yarn install",
|
||||||
|
"dev": "cd pm_app && yarn dev",
|
||||||
|
"build": "cd pm_app && yarn build"
|
||||||
|
},
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
24
pm_app/.gitignore
vendored
Normal file
24
pm_app/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
23
pm_app/eslint.config.js
Normal file
23
pm_app/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
16
pm_app/index.html
Normal file
16
pm_app/index.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/seera-logo.png?v=1781184737" />
|
||||||
|
<link rel="apple-touch-icon" href="/seera-logo.png?v=1781184737" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Project Management System" />
|
||||||
|
<title>Project Management</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script>window.csrf_token = '{{ frappe.session.csrf_token }}';</script>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
49
pm_app/package.json
Normal file
49
pm_app/package.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "pm_app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "node -e \"require('fs').mkdirSync(require('path').join('..','project_management','public','pm_app'),{recursive:true})\" && node scripts/inject-image-version.js && vite build --base=/assets/project_management/pm_app/ && yarn copy-html-entry && yarn copy-public-assets",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"copy-html-entry": "cp ../project_management/public/pm_app/index.html ../project_management/www/project_management.html",
|
||||||
|
"copy-public-assets": "cp public/sidebar-background.jpg ../project_management/public/pm_app/sidebar-background.jpg 2>/dev/null || true && cp public/seera-logo.png ../project_management/public/pm_app/seera-logo.png 2>/dev/null || true"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"frappe-react-sdk": "^1.13.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"i18next": "^25.7.2",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-i18next": "^16.4.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-is": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.9.4",
|
||||||
|
"react-toastify": "^11.0.5",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@types/react": "^19.1.16",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.45.0",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
pm_app/postcss.config.js
Normal file
6
pm_app/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
13
pm_app/proxyOptions.ts
Normal file
13
pm_app/proxyOptions.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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}:${webserver_port}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
pm_app/public/seera-logo.png
Normal file
BIN
pm_app/public/seera-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
pm_app/public/sidebar-background.jpg
Normal file
BIN
pm_app/public/sidebar-background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
1
pm_app/public/vite.svg
Normal file
1
pm_app/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
66
pm_app/scripts/inject-image-version.js
Normal file
66
pm_app/scripts/inject-image-version.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { statSync } from 'fs';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// Get image modification times
|
||||||
|
const sidebarBgPath = join(process.cwd(), 'public', 'sidebar-background.jpg');
|
||||||
|
const logoPath = join(process.cwd(), 'public', 'seera-logo.png');
|
||||||
|
const sidebarPath = join(process.cwd(), 'src', 'components', 'Sidebar.tsx');
|
||||||
|
const loginPath = join(process.cwd(), 'src', 'pages', 'Login.tsx');
|
||||||
|
const indexPath = join(process.cwd(), 'index.html');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get sidebar background image modification time
|
||||||
|
const sidebarBgStats = statSync(sidebarBgPath);
|
||||||
|
const sidebarBgMtime = Math.floor(sidebarBgStats.mtimeMs / 1000);
|
||||||
|
|
||||||
|
// Get logo modification time
|
||||||
|
const logoStats = statSync(logoPath);
|
||||||
|
const logoMtime = Math.floor(logoStats.mtimeMs / 1000);
|
||||||
|
|
||||||
|
// Update Sidebar.tsx
|
||||||
|
let sidebarContent = readFileSync(sidebarPath, 'utf8');
|
||||||
|
|
||||||
|
// Update sidebar background version constant
|
||||||
|
sidebarContent = sidebarContent.replace(
|
||||||
|
/(const imageVersion = import\.meta\.env\.DEV[\s\S]*?`\?v=)([\d]+)(`; \/\/ Auto-updated by build script)/,
|
||||||
|
`$1${sidebarBgMtime}$3`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update logo version constant
|
||||||
|
sidebarContent = sidebarContent.replace(
|
||||||
|
/(const logoVersion = import\.meta\.env\.DEV[\s\S]*?`\?v=)([\d]+)(`; \/\/ Auto-updated by build script)/,
|
||||||
|
`$1${logoMtime}$3`
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(sidebarPath, sidebarContent, 'utf8');
|
||||||
|
console.log(`✓ Updated sidebar background image version to ${sidebarBgMtime}`);
|
||||||
|
console.log(`✓ Updated seera-logo.png version to ${logoMtime} in Sidebar.tsx`);
|
||||||
|
|
||||||
|
// Update Login.tsx
|
||||||
|
let loginContent = readFileSync(loginPath, 'utf8');
|
||||||
|
|
||||||
|
// Update logo version constant
|
||||||
|
loginContent = loginContent.replace(
|
||||||
|
/(const logoVersion = import\.meta\.env\.DEV[\s\S]*?`\?v=)([\d]+)(`; \/\/ Auto-updated by build script)/,
|
||||||
|
`$1${logoMtime}$3`
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(loginPath, loginContent, 'utf8');
|
||||||
|
console.log(`✓ Updated seera-logo.png version to ${logoMtime} in Login.tsx`);
|
||||||
|
|
||||||
|
// Update index.html favicon
|
||||||
|
let indexContent = readFileSync(indexPath, 'utf8');
|
||||||
|
|
||||||
|
// Update favicon version
|
||||||
|
indexContent = indexContent.replace(
|
||||||
|
/seera-logo\.png(\?v=[\d]+)?/g,
|
||||||
|
`seera-logo.png?v=${logoMtime}`
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(indexPath, indexContent, 'utf8');
|
||||||
|
console.log(`✓ Updated seera-logo.png version to ${logoMtime} in index.html`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠ Could not update image versions:', error.message);
|
||||||
|
}
|
||||||
150
pm_app/src/App.tsx
Normal file
150
pm_app/src/App.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { bootstrapFrappeUserFromSession } from './utils/bootstrapFrappeUserFromSession';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Sidebar from './components/Sidebar';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import UserProfilePage from './pages/UserProfilePage';
|
||||||
|
import ProjectModulePage from './pages/ProjectModulePage';
|
||||||
|
import ProjectReportsDashboard from './pages/ProjectReportsDashboard';
|
||||||
|
import ProjectList from './pages/ProjectList';
|
||||||
|
import ProjectDetail from './pages/ProjectDetail';
|
||||||
|
import TaskList from './pages/TaskList';
|
||||||
|
import TaskDetail from './pages/TaskDetail';
|
||||||
|
import TimesheetList from './pages/TimesheetList';
|
||||||
|
import TimesheetDetail from './pages/TimesheetDetail';
|
||||||
|
import ActivityTypeList from './pages/ActivityTypeList';
|
||||||
|
import ActivityTypeDetail from './pages/ActivityTypeDetail';
|
||||||
|
import ProjectTemplateList from './pages/ProjectTemplateList';
|
||||||
|
import ProjectTemplateDetail from './pages/ProjectTemplateDetail';
|
||||||
|
import CustomerList from './pages/CustomerList';
|
||||||
|
import CustomerDetail from './pages/CustomerDetail';
|
||||||
|
import EmployeeList from './pages/EmployeeList';
|
||||||
|
import EmployeeDetail from './pages/EmployeeDetail';
|
||||||
|
import SalesInvoiceList from './pages/SalesInvoiceList';
|
||||||
|
import SalesInvoiceDetail from './pages/SalesInvoiceDetail';
|
||||||
|
import SalesOrderList from './pages/SalesOrderList';
|
||||||
|
import SalesOrderDetail from './pages/SalesOrderDetail';
|
||||||
|
import PurchaseOrderList from './pages/PurchaseOrderList';
|
||||||
|
import PurchaseOrderDetail from './pages/PurchaseOrderDetail';
|
||||||
|
import DeliveryNoteList from './pages/DeliveryNoteList';
|
||||||
|
import DeliveryNoteDetail from './pages/DeliveryNoteDetail';
|
||||||
|
import MaterialRequestList from './pages/MaterialRequestList';
|
||||||
|
import MaterialRequestDetail from './pages/MaterialRequestDetail';
|
||||||
|
import PurchaseReceiptList from './pages/PurchaseReceiptList';
|
||||||
|
import PurchaseReceiptDetail from './pages/PurchaseReceiptDetail';
|
||||||
|
import PaymentEntryList from './pages/PaymentEntryList';
|
||||||
|
import PaymentEntryDetail from './pages/PaymentEntryDetail';
|
||||||
|
import { SidebarLayoutProvider } from './contexts/SidebarLayoutContext';
|
||||||
|
|
||||||
|
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const user = localStorage.getItem('user');
|
||||||
|
const userEmail = user ? JSON.parse(user).email : '';
|
||||||
|
return (
|
||||||
|
<SidebarLayoutProvider>
|
||||||
|
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Sidebar userEmail={userEmail} />
|
||||||
|
<div className="pm-app-main flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<Header userEmail={userEmail} />
|
||||||
|
<div className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarLayoutProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [status, setStatus] = useState<'loading' | 'authed' | 'guest'>('loading');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (localStorage.getItem('user')) {
|
||||||
|
if (!cancelled) setStatus('authed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await bootstrapFrappeUserFromSession();
|
||||||
|
if (!cancelled) setStatus(result.ok ? 'authed' : 'guest');
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="h-10 w-10 animate-spin text-indigo-600"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'guest') {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: React.FC = () => (
|
||||||
|
<Router basename="/project_management">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/user-profile" element={<ProtectedRoute><LayoutWithSidebar><UserProfilePage /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects" element={<ProtectedRoute><LayoutWithSidebar><ProjectModulePage /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/reports" element={<ProtectedRoute><LayoutWithSidebar><ProjectReportsDashboard /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/project-updates" element={<Navigate to="/projects" replace />} />
|
||||||
|
<Route path="/projects/project-updates/:updateName" element={<Navigate to="/projects" replace />} />
|
||||||
|
<Route path="/projects/list" element={<ProtectedRoute><LayoutWithSidebar><ProjectList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/list/:projectName" element={<ProtectedRoute><LayoutWithSidebar><ProjectDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/tasks" element={<ProtectedRoute><LayoutWithSidebar><TaskList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/tasks/:taskName" element={<ProtectedRoute><LayoutWithSidebar><TaskDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/timesheets" element={<ProtectedRoute><LayoutWithSidebar><TimesheetList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/timesheets/:timesheetName" element={<ProtectedRoute><LayoutWithSidebar><TimesheetDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/activity-types" element={<ProtectedRoute><LayoutWithSidebar><ActivityTypeList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/activity-types/:activityTypeName" element={<ProtectedRoute><LayoutWithSidebar><ActivityTypeDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/templates" element={<ProtectedRoute><LayoutWithSidebar><ProjectTemplateList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/projects/templates/:templateName" element={<ProtectedRoute><LayoutWithSidebar><ProjectTemplateDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/customers" element={<ProtectedRoute><LayoutWithSidebar><CustomerList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/customers/:customerName" element={<ProtectedRoute><LayoutWithSidebar><CustomerDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/employees" element={<ProtectedRoute><LayoutWithSidebar><EmployeeList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/employees/:employeeName" element={<ProtectedRoute><LayoutWithSidebar><EmployeeDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/invoices" element={<ProtectedRoute><LayoutWithSidebar><SalesInvoiceList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/invoices/:invoiceName" element={<ProtectedRoute><LayoutWithSidebar><SalesInvoiceDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/sales-orders" element={<ProtectedRoute><LayoutWithSidebar><SalesOrderList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/sales-orders/:soName" element={<ProtectedRoute><LayoutWithSidebar><SalesOrderDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/purchase-orders" element={<ProtectedRoute><LayoutWithSidebar><PurchaseOrderList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/purchase-orders/:poName" element={<ProtectedRoute><LayoutWithSidebar><PurchaseOrderDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/delivery-notes" element={<ProtectedRoute><LayoutWithSidebar><DeliveryNoteList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/delivery-notes/:dnName" element={<ProtectedRoute><LayoutWithSidebar><DeliveryNoteDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/material-requests" element={<ProtectedRoute><LayoutWithSidebar><MaterialRequestList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/material-requests/:mrName" element={<ProtectedRoute><LayoutWithSidebar><MaterialRequestDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/purchase-receipts" element={<ProtectedRoute><LayoutWithSidebar><PurchaseReceiptList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/purchase-receipts/:prName" element={<ProtectedRoute><LayoutWithSidebar><PurchaseReceiptDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/payment-entries" element={<ProtectedRoute><LayoutWithSidebar><PaymentEntryList /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/payment-entries/:peName" element={<ProtectedRoute><LayoutWithSidebar><PaymentEntryDetail /></LayoutWithSidebar></ProtectedRoute>} />
|
||||||
|
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
pm_app/src/assets/audio/ar_no_selection.mp3
Normal file
BIN
pm_app/src/assets/audio/ar_no_selection.mp3
Normal file
Binary file not shown.
BIN
pm_app/src/assets/audio/ar_prompt.mp3
Normal file
BIN
pm_app/src/assets/audio/ar_prompt.mp3
Normal file
Binary file not shown.
BIN
pm_app/src/assets/audio/ar_task_prompt.mp3
Normal file
BIN
pm_app/src/assets/audio/ar_task_prompt.mp3
Normal file
Binary file not shown.
BIN
pm_app/src/assets/audio/en_no_selection_prompt.mp3
Normal file
BIN
pm_app/src/assets/audio/en_no_selection_prompt.mp3
Normal file
Binary file not shown.
BIN
pm_app/src/assets/audio/en_status_prompt.mp3
Normal file
BIN
pm_app/src/assets/audio/en_status_prompt.mp3
Normal file
Binary file not shown.
BIN
pm_app/src/assets/audio/en_task_prompt.mp3
Normal file
BIN
pm_app/src/assets/audio/en_task_prompt.mp3
Normal file
Binary file not shown.
450
pm_app/src/components/ActivityLog.tsx
Normal file
450
pm_app/src/components/ActivityLog.tsx
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
FaHistory,
|
||||||
|
FaSync,
|
||||||
|
FaChevronDown,
|
||||||
|
FaChevronUp,
|
||||||
|
FaUser,
|
||||||
|
FaClock,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaSpinner,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { useAuditLogs } from '../hooks/useAuditLogs';
|
||||||
|
import type { AuditLogEntry, VersionChange } from '../hooks/useAuditLogs';
|
||||||
|
|
||||||
|
// ============== PROPS ==============
|
||||||
|
|
||||||
|
interface ActivityLogProps {
|
||||||
|
/** Frappe DocType name (e.g. 'Asset', 'Inspection', 'Work_Order') */
|
||||||
|
doctype: string;
|
||||||
|
/** Document name / ID */
|
||||||
|
docname: string | null;
|
||||||
|
/** Document creation date (for "Created" entry at bottom) */
|
||||||
|
creationDate?: string;
|
||||||
|
/** Document owner/creator email */
|
||||||
|
createdBy?: string;
|
||||||
|
/** Title shown in header */
|
||||||
|
title?: string;
|
||||||
|
/** Max entries to fetch */
|
||||||
|
limit?: number;
|
||||||
|
/** Number of entries visible before "Show All" */
|
||||||
|
initialVisible?: number;
|
||||||
|
/** Allow collapse/expand */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** Start collapsed */
|
||||||
|
startCollapsed?: boolean;
|
||||||
|
/** Compact mode for sidebar placement */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Additional CSS class */
|
||||||
|
className?: string;
|
||||||
|
/** Callback after refresh */
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== HELPER FUNCTIONS ==============
|
||||||
|
|
||||||
|
const formatFieldName = (fieldName: string): string => {
|
||||||
|
if (!fieldName) return '';
|
||||||
|
return fieldName
|
||||||
|
.replace(/^custom_/, '')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return '(empty)';
|
||||||
|
if (value === '') return '(empty)';
|
||||||
|
if (value === 0) return '0';
|
||||||
|
if (value === 1) return '1';
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAuditDate = (dateStr: string): string => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUsername = (email: string): string => {
|
||||||
|
if (!email) return 'Unknown';
|
||||||
|
const atIndex = email.indexOf('@');
|
||||||
|
if (atIndex === -1) return email;
|
||||||
|
return email.substring(0, atIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangeColor = (fieldName: string): string => {
|
||||||
|
const lower = fieldName.toLowerCase();
|
||||||
|
if (lower.includes('status') || lower.includes('state') || lower.includes('workflow')) {
|
||||||
|
return 'text-purple-600 dark:text-purple-400';
|
||||||
|
}
|
||||||
|
if (lower.includes('date')) {
|
||||||
|
return 'text-blue-600 dark:text-blue-400';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lower.includes('technician') ||
|
||||||
|
lower.includes('supervisor') ||
|
||||||
|
lower.includes('assigned') ||
|
||||||
|
lower.includes('location') ||
|
||||||
|
lower.includes('department') ||
|
||||||
|
lower.includes('building') ||
|
||||||
|
lower.includes('room')
|
||||||
|
) {
|
||||||
|
return 'text-green-600 dark:text-green-400';
|
||||||
|
}
|
||||||
|
return 'text-gray-600 dark:text-gray-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============== SUB-COMPONENTS ==============
|
||||||
|
|
||||||
|
/** Single timeline entry */
|
||||||
|
const TimelineEntry: React.FC<{
|
||||||
|
log: AuditLogEntry;
|
||||||
|
isLatest: boolean;
|
||||||
|
compact: boolean;
|
||||||
|
}> = ({ log, isLatest, compact }) => {
|
||||||
|
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
|
||||||
|
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
|
||||||
|
const iconSize = compact ? 8 : 10;
|
||||||
|
const textSize = compact ? 'text-[10px]' : 'text-xs';
|
||||||
|
const valueSize = compact ? 'text-[9px]' : 'text-[10px]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div
|
||||||
|
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 ${
|
||||||
|
isLatest ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Entry content */}
|
||||||
|
<div
|
||||||
|
className={`${compact ? 'p-2' : 'p-3'} rounded-lg ${
|
||||||
|
isLatest
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800/50'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={`${avatarSize} rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
<FaUser className="text-gray-500 dark:text-gray-400" size={iconSize} />
|
||||||
|
</div>
|
||||||
|
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
|
||||||
|
{formatUsername(log.owner)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
|
||||||
|
<FaClock size={iconSize} />
|
||||||
|
<span title={new Date(log.creation).toLocaleString()}>
|
||||||
|
{formatAuditDate(log.creation)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Changes */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{log.changes.length > 0 ? (
|
||||||
|
log.changes.map((change, i) => (
|
||||||
|
<div key={i} className={textSize}>
|
||||||
|
<span className={`font-medium ${getChangeColor(change.field)}`}>
|
||||||
|
{formatFieldName(change.field)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400"> changed from </span>
|
||||||
|
<span
|
||||||
|
className={`px-1 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded ${valueSize} font-mono`}
|
||||||
|
>
|
||||||
|
{formatValue(change.oldValue)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400"> → </span>
|
||||||
|
<span
|
||||||
|
className={`px-1 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded ${valueSize} font-mono`}
|
||||||
|
>
|
||||||
|
{formatValue(change.newValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className={`${textSize} text-gray-500 dark:text-gray-400 italic`}>Document updated</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.added && log.added.length > 0 && (
|
||||||
|
<div className={`${textSize} text-green-600 dark:text-green-400`}>
|
||||||
|
<span className="font-medium">Added:</span> {log.added.length} item(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.removed && log.removed.length > 0 && (
|
||||||
|
<div className={`${textSize} text-red-600 dark:text-red-400`}>
|
||||||
|
<span className="font-medium">Removed:</span> {log.removed.length} item(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.rowChanged && log.rowChanged.length > 0 && (
|
||||||
|
<div className={`${textSize} text-orange-600 dark:text-orange-400`}>
|
||||||
|
<span className="font-medium">Modified:</span> {log.rowChanged.length} row(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "Created this document" entry */
|
||||||
|
const CreatedEntry: React.FC<{
|
||||||
|
creationDate: string;
|
||||||
|
createdBy: string;
|
||||||
|
doctype: string;
|
||||||
|
compact: boolean;
|
||||||
|
}> = ({ creationDate, createdBy, doctype, compact }) => {
|
||||||
|
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
|
||||||
|
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
|
||||||
|
const iconSize = compact ? 8 : 10;
|
||||||
|
const textSize = compact ? 'text-[10px]' : 'text-xs';
|
||||||
|
|
||||||
|
// Clean doctype for display (e.g. "Work_Order" → "Work Order")
|
||||||
|
const displayDoctype = doctype.replace(/_/g, ' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
|
||||||
|
<div
|
||||||
|
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 bg-green-500`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800/50`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={`${avatarSize} rounded-full bg-green-200 dark:bg-green-800 flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
<FaUser className="text-green-600 dark:text-green-400" size={iconSize} />
|
||||||
|
</div>
|
||||||
|
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
|
||||||
|
{formatUsername(createdBy)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
|
||||||
|
<FaClock size={iconSize} />
|
||||||
|
<span title={new Date(creationDate).toLocaleString()}>
|
||||||
|
{formatAuditDate(creationDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 bg-green-100 dark:bg-green-800/50 text-green-700 dark:text-green-300 rounded ${textSize} font-medium`}
|
||||||
|
>
|
||||||
|
<FaCheckCircle size={iconSize} />
|
||||||
|
Created this {displayDoctype}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============== MAIN COMPONENT ==============
|
||||||
|
|
||||||
|
const ActivityLog: React.FC<ActivityLogProps> = ({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
creationDate,
|
||||||
|
createdBy,
|
||||||
|
title = 'Activity Log',
|
||||||
|
limit = 50,
|
||||||
|
initialVisible = 5,
|
||||||
|
collapsible = true,
|
||||||
|
startCollapsed = false,
|
||||||
|
compact = false,
|
||||||
|
className = '',
|
||||||
|
onRefresh,
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(!startCollapsed);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
|
const { auditLogs, loading, refetch } = useAuditLogs({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
limit,
|
||||||
|
enabled: !!docname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
refetch();
|
||||||
|
onRefresh?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!docname) return null;
|
||||||
|
|
||||||
|
const headerIconSize = compact ? 14 : 16;
|
||||||
|
const headerTextClass = compact ? 'text-sm' : 'text-base';
|
||||||
|
const timelineLineLeft = compact ? 'left-2' : 'left-3';
|
||||||
|
const showMoreTextSize = compact ? 'text-[10px]' : 'text-xs';
|
||||||
|
const showMoreIconSize = compact ? 8 : 10;
|
||||||
|
|
||||||
|
const visibleLogs = showAll ? auditLogs : auditLogs.slice(0, initialVisible);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 flex-1 ${collapsible ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={() => collapsible && setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<FaHistory className="text-blue-500" size={headerIconSize} />
|
||||||
|
<h2 className={`${headerTextClass} font-semibold text-gray-800 dark:text-white`}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{auditLogs.length > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-[10px] font-medium">
|
||||||
|
{auditLogs.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRefresh();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh activity log"
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'animate-spin' : ''} size={compact ? 10 : 12} />
|
||||||
|
</button>
|
||||||
|
{collapsible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<FaChevronUp size={compact ? 12 : 14} />
|
||||||
|
) : (
|
||||||
|
<FaChevronDown size={compact ? 12 : 14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-3">
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<FaSpinner className="animate-spin text-blue-500 mr-2" size={14} />
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">Loading...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && auditLogs.length === 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
|
||||||
|
|
||||||
|
<div className={`relative ${compact ? 'pl-6' : 'pl-8'} mb-3`}>
|
||||||
|
<div
|
||||||
|
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1 ${compact ? 'w-2.5 h-2.5' : 'w-3 h-3'} rounded-full border-2 border-white dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
|
||||||
|
/>
|
||||||
|
<div className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-gray-50 dark:bg-gray-700/50`}>
|
||||||
|
<p className={`${compact ? 'text-[10px]' : 'text-xs'} text-gray-500 dark:text-gray-400 italic`}>
|
||||||
|
No changes recorded yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creationDate && createdBy && (
|
||||||
|
<CreatedEntry
|
||||||
|
creationDate={creationDate}
|
||||||
|
createdBy={createdBy}
|
||||||
|
doctype={doctype}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{!loading && auditLogs.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visibleLogs.map((log, index) => (
|
||||||
|
<TimelineEntry
|
||||||
|
key={log.name}
|
||||||
|
log={log}
|
||||||
|
isLatest={index === 0}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show More/Less */}
|
||||||
|
{auditLogs.length > initialVisible && (
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-1 ${showMoreTextSize} font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors`}
|
||||||
|
>
|
||||||
|
{showAll ? (
|
||||||
|
<>
|
||||||
|
<FaChevronUp size={showMoreIconSize} /> Show Less
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaChevronDown size={showMoreIconSize} /> Show All ({auditLogs.length})
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Created entry at bottom */}
|
||||||
|
{creationDate && createdBy && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<CreatedEntry
|
||||||
|
creationDate={creationDate}
|
||||||
|
createdBy={createdBy}
|
||||||
|
doctype={doctype}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityLog;
|
||||||
519
pm_app/src/components/DynamicExportModal.tsx
Normal file
519
pm_app/src/components/DynamicExportModal.tsx
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
|
FaFileExport, FaTimes, FaFileCsv, FaFileExcel, FaDownload,
|
||||||
|
FaSearch, FaCheckSquare, FaSquare, FaSpinner,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { useDoctypeFields, type DoctypeField } from '../hooks/useDoctypeFields';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Types
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ExportFormat = 'csv' | 'excel';
|
||||||
|
export type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
|
||||||
|
|
||||||
|
export interface DynamicExportModalProps {
|
||||||
|
/** Whether the modal is open */
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
|
||||||
|
/** Frappe DocType name, e.g. "Work_Order", "Asset" */
|
||||||
|
doctype: string;
|
||||||
|
|
||||||
|
/** Counts for the three scope options */
|
||||||
|
selectedCount: number;
|
||||||
|
pageCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user clicks Export.
|
||||||
|
* `rows` is the flat array of objects to export (already resolved by parent).
|
||||||
|
* `columns` is the list of chosen column keys.
|
||||||
|
* `format` is 'csv' | 'excel'.
|
||||||
|
*
|
||||||
|
* Alternatively you can pass `onFetchAll` and let the modal handle fetching.
|
||||||
|
*/
|
||||||
|
onExport?: (scope: ExportScope, format: ExportFormat, columns: string[]) => Promise<void> | void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If provided, the modal will call this to fetch ALL records when
|
||||||
|
* scope === 'all_with_filters'. The parent just needs to provide page data.
|
||||||
|
*/
|
||||||
|
onFetchAll?: () => Promise<any[]>;
|
||||||
|
|
||||||
|
/** Current page data (used for 'all_on_page' and 'selected') */
|
||||||
|
pageData: any[];
|
||||||
|
|
||||||
|
/** Set of selected row names/ids */
|
||||||
|
selectedRows?: Set<string>;
|
||||||
|
rowKey?: string; // default: 'name'
|
||||||
|
|
||||||
|
/** Optional: extra columns to inject (e.g. computed / virtual fields) */
|
||||||
|
extraColumns?: DoctypeField[];
|
||||||
|
|
||||||
|
/** Optional: columns to hide even if they exist in DocType */
|
||||||
|
hiddenColumns?: string[];
|
||||||
|
|
||||||
|
/** Optional: override default-checked columns (fieldnames) */
|
||||||
|
defaultColumns?: string[];
|
||||||
|
|
||||||
|
/** File name prefix, e.g. "work_orders" → "work_orders_2025-01-01.csv" */
|
||||||
|
fileNamePrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatValue(value: any): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCSV(rows: any[], columns: DoctypeField[], fileName: string) {
|
||||||
|
const headers = columns.map(c => c.label);
|
||||||
|
const body = rows.map(row =>
|
||||||
|
columns.map(c => {
|
||||||
|
const val = formatValue(row[c.key]);
|
||||||
|
// Escape CSV
|
||||||
|
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||||
|
return `"${val.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}).join(',')
|
||||||
|
);
|
||||||
|
const csv = [headers.join(','), ...body].join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadExcel(rows: any[], columns: DoctypeField[], fileName: string) {
|
||||||
|
const wsData = [
|
||||||
|
columns.map(c => c.label),
|
||||||
|
...rows.map(row => columns.map(c => formatValue(row[c.key]))),
|
||||||
|
];
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Export');
|
||||||
|
XLSX.writeFile(wb, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Component
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DynamicExportModal: React.FC<DynamicExportModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
doctype,
|
||||||
|
selectedCount,
|
||||||
|
pageCount,
|
||||||
|
totalCount,
|
||||||
|
onExport,
|
||||||
|
onFetchAll,
|
||||||
|
pageData,
|
||||||
|
selectedRows,
|
||||||
|
rowKey = 'name',
|
||||||
|
extraColumns = [],
|
||||||
|
hiddenColumns = [],
|
||||||
|
defaultColumns,
|
||||||
|
fileNamePrefix,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { fields, loading: fieldsLoading } = useDoctypeFields(doctype);
|
||||||
|
|
||||||
|
// ── Derived column list ──────────────────────────────────────
|
||||||
|
const allColumns: DoctypeField[] = React.useMemo(() => {
|
||||||
|
const hidden = new Set(hiddenColumns);
|
||||||
|
|
||||||
|
// Merge fetched fields + extra columns, remove hidden
|
||||||
|
const base = [
|
||||||
|
...fields.filter(f => !hidden.has(f.key)),
|
||||||
|
...extraColumns.filter(f => !hidden.has(f.key)),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply defaultColumns override if provided
|
||||||
|
if (defaultColumns) {
|
||||||
|
const defaultSet = new Set(defaultColumns);
|
||||||
|
return base.map(f => ({ ...f, default: defaultSet.has(f.key) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}, [fields, extraColumns, hiddenColumns, defaultColumns]);
|
||||||
|
|
||||||
|
// ── Local state ───────────────────────────────────────────────
|
||||||
|
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
|
||||||
|
const [format, setFormat] = useState<ExportFormat>('csv');
|
||||||
|
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
// Track whether we have seeded checkedKeys for this modal open session
|
||||||
|
const initializedRef = React.useRef(false);
|
||||||
|
|
||||||
|
// Sync scope when selectedCount changes
|
||||||
|
useEffect(() => {
|
||||||
|
setScope(selectedCount > 0 ? 'selected' : 'all_with_filters');
|
||||||
|
}, [selectedCount]);
|
||||||
|
|
||||||
|
// Seed default checked columns ONCE when allColumns first populates.
|
||||||
|
// Using a ref guard prevents re-seeding when allColumns recomputes due to
|
||||||
|
// inline prop arrays (defaultColumns={[...]} creates a new reference every
|
||||||
|
// parent render), which would silently wipe out the user's All/None/Default selection.
|
||||||
|
useEffect(() => {
|
||||||
|
if (allColumns.length === 0) return;
|
||||||
|
if (initializedRef.current) return;
|
||||||
|
initializedRef.current = true;
|
||||||
|
setCheckedKeys(new Set(allColumns.filter(c => c.default).map(c => c.key)));
|
||||||
|
}, [allColumns]);
|
||||||
|
|
||||||
|
// Reset the seed flag when modal closes so next open re-initializes cleanly
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
initializedRef.current = false;
|
||||||
|
setCheckedKeys(new Set());
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// ── Column helpers ────────────────────────────────────────────
|
||||||
|
const filteredColumns = search.trim()
|
||||||
|
? allColumns.filter(c =>
|
||||||
|
c.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
c.key.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: allColumns;
|
||||||
|
|
||||||
|
const toggleColumn = (key: string) => {
|
||||||
|
setCheckedKeys(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => setCheckedKeys(new Set(allColumns.map(c => c.key)));
|
||||||
|
const selectDefault = () => setCheckedKeys(new Set(allColumns.filter(c => c.default).map(c => c.key)));
|
||||||
|
const selectNone = () => setCheckedKeys(new Set());
|
||||||
|
|
||||||
|
// ── Export handler ────────────────────────────────────────────
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (checkedKeys.size === 0) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If parent handles everything
|
||||||
|
if (onExport) {
|
||||||
|
await onExport(scope, format, [...checkedKeys]);
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise handle internally
|
||||||
|
let rows: any[] = [];
|
||||||
|
if (scope === 'selected') {
|
||||||
|
const sel = selectedRows ?? new Set<string>();
|
||||||
|
rows = pageData.filter(r => sel.has(r[rowKey]));
|
||||||
|
} else if (scope === 'all_on_page') {
|
||||||
|
rows = pageData;
|
||||||
|
} else {
|
||||||
|
if (!onFetchAll) {
|
||||||
|
alert('onFetchAll not provided for all_with_filters scope');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows = await onFetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
alert('No data to export.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosenCols = allColumns.filter(c => checkedKeys.has(c.key));
|
||||||
|
const prefix = fileNamePrefix ?? doctype.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const datePart = new Date().toISOString().split('T')[0];
|
||||||
|
const fileName = `${prefix}_export_${datePart}.${format === 'csv' ? 'csv' : 'xlsx'}`;
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
downloadCSV(rows, chosenCols, fileName);
|
||||||
|
} else {
|
||||||
|
downloadExcel(rows, chosenCols, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err);
|
||||||
|
alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[92vh] flex flex-col animate-scale-in">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4 rounded-t-lg flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFileExport className="text-white text-xl" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">Export {doctype.replace(/_/g, ' ')}</h3>
|
||||||
|
<p className="text-green-100 text-xs mt-0.5">
|
||||||
|
{allColumns.length} fields available · {checkedKeys.size} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body (scrollable) ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Scope */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">What to export</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Selected rows */}
|
||||||
|
<ScopeOption
|
||||||
|
value="selected"
|
||||||
|
current={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
badge={selectedCount}
|
||||||
|
badgeColor="green"
|
||||||
|
label="Selected rows"
|
||||||
|
sub={`${selectedCount} row${selectedCount !== 1 ? 's' : ''} selected`}
|
||||||
|
/>
|
||||||
|
{/* Current page */}
|
||||||
|
<ScopeOption
|
||||||
|
value="all_on_page"
|
||||||
|
current={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
badge={pageCount}
|
||||||
|
badgeColor="blue"
|
||||||
|
label="Current page"
|
||||||
|
sub={`${pageCount} rows on this page`}
|
||||||
|
/>
|
||||||
|
{/* All with filters */}
|
||||||
|
<ScopeOption
|
||||||
|
value="all_with_filters"
|
||||||
|
current={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
badge={totalCount}
|
||||||
|
badgeColor="purple"
|
||||||
|
label="All records (current filters)"
|
||||||
|
sub={`${totalCount} total matching records`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">File format</h4>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<FormatOption value="csv" current={format} onChange={setFormat}
|
||||||
|
icon={<FaFileCsv className="text-green-600 text-xl" />}
|
||||||
|
label="CSV" sub="Universal, works everywhere" />
|
||||||
|
<FormatOption value="excel" current={format} onChange={setFormat}
|
||||||
|
icon={<FaFileExcel className="text-green-700 text-xl" />}
|
||||||
|
label="Excel (.xlsx)" sub="Native Excel workbook" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column picker */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Columns to export
|
||||||
|
{fieldsLoading && <FaSpinner className="inline ml-2 animate-spin text-gray-400" size={12} />}
|
||||||
|
</h4>
|
||||||
|
<div className="flex gap-3 text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
<button onClick={selectAll} className="hover:underline">All</button>
|
||||||
|
<button onClick={selectDefault} className="hover:underline">Default</button>
|
||||||
|
<button onClick={selectNone} className="hover:underline">None</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mb-2">
|
||||||
|
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" size={12} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search fields…"
|
||||||
|
className="w-full pl-8 pr-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||||
|
<FaTimes size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field grid */}
|
||||||
|
{fieldsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-24 text-gray-400 text-sm gap-2">
|
||||||
|
<FaSpinner className="animate-spin" /> Loading fields…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-1.5 max-h-52 overflow-y-auto p-2 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
{filteredColumns.map(col => {
|
||||||
|
const checked = checkedKeys.has(col.key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
onClick={() => toggleColumn(col.key)}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-all text-xs select-none ${
|
||||||
|
checked
|
||||||
|
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'
|
||||||
|
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0">
|
||||||
|
{checked
|
||||||
|
? <FaCheckSquare size={13} className="text-green-600" />
|
||||||
|
: <FaSquare size={13} className="text-gray-300 dark:text-gray-600" />}
|
||||||
|
</span>
|
||||||
|
<span className="truncate" title={`${col.label} (${col.key})`}>
|
||||||
|
{col.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredColumns.length === 0 && (
|
||||||
|
<p className="col-span-3 text-center text-gray-400 text-xs py-4">
|
||||||
|
No fields match "{search}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 mt-1.5">
|
||||||
|
{checkedKeys.size} of {allColumns.length} fields selected
|
||||||
|
{search && ` · showing ${filteredColumns.length} matching`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer ── */}
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 rounded-b-lg flex justify-between items-center">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{scope === 'selected' && `Exporting ${selectedCount} selected row${selectedCount !== 1 ? 's' : ''}`}
|
||||||
|
{scope === 'all_on_page' && `Exporting ${pageCount} rows from current page`}
|
||||||
|
{scope === 'all_with_filters' && `Exporting up to ${totalCount} records`}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={checkedKeys.size === 0 || isExporting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<><FaSpinner className="animate-spin" size={14} /> Exporting…</>
|
||||||
|
) : (
|
||||||
|
<><FaDownload size={14} /> Export</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Sub-components
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BADGE_COLORS: Record<string, string> = {
|
||||||
|
green: 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300',
|
||||||
|
blue: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
|
||||||
|
purple: 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ScopeOptionProps {
|
||||||
|
value: ExportScope;
|
||||||
|
current: ExportScope;
|
||||||
|
onChange: (v: ExportScope) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
badge: number;
|
||||||
|
badgeColor: 'green' | 'blue' | 'purple';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScopeOption: React.FC<ScopeOptionProps> = ({ value, current, onChange, disabled, label, sub, badge, badgeColor }) => (
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
||||||
|
current === value
|
||||||
|
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="radio" name="export_scope" value={value}
|
||||||
|
checked={current === value}
|
||||||
|
onChange={() => !disabled && onChange(value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-green-600 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-sm text-gray-900 dark:text-white">{label}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{sub}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-semibold flex-shrink-0 ${BADGE_COLORS[badgeColor]}`}>
|
||||||
|
{badge.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface FormatOptionProps {
|
||||||
|
value: ExportFormat;
|
||||||
|
current: ExportFormat;
|
||||||
|
onChange: (v: ExportFormat) => void;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormatOption: React.FC<FormatOptionProps> = ({ value, current, onChange, icon, label, sub }) => (
|
||||||
|
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
||||||
|
current === value
|
||||||
|
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
|
}`}>
|
||||||
|
<input
|
||||||
|
type="radio" name="export_format" value={value}
|
||||||
|
checked={current === value}
|
||||||
|
onChange={() => onChange(value)}
|
||||||
|
className="text-green-600 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
{icon}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm text-gray-900 dark:text-white">{label}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{sub}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DynamicExportModal;
|
||||||
376
pm_app/src/components/DynamicField.tsx
Normal file
376
pm_app/src/components/DynamicField.tsx
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* DynamicField Component
|
||||||
|
*
|
||||||
|
* Renders form fields dynamically based on Frappe's field configuration.
|
||||||
|
* Supports conditional visibility, mandatory, read-only states, and various field types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { type FieldConfig, evaluateFieldState, parseSelectOptions, getInputType } from '../utils/frappeExpressionEvaluator';
|
||||||
|
import LinkField from './LinkField';
|
||||||
|
|
||||||
|
interface DynamicFieldProps {
|
||||||
|
fieldConfig: FieldConfig;
|
||||||
|
value: any;
|
||||||
|
onChange: (fieldname: string, value: any) => void;
|
||||||
|
doc: Record<string, any>;
|
||||||
|
disabled?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||||
|
fieldConfig,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
doc,
|
||||||
|
disabled = false,
|
||||||
|
compact = false,
|
||||||
|
className = '',
|
||||||
|
error
|
||||||
|
}) => {
|
||||||
|
// Evaluate field state based on current document
|
||||||
|
const fieldState = useMemo(() => {
|
||||||
|
return evaluateFieldState(fieldConfig, doc);
|
||||||
|
}, [fieldConfig, doc]);
|
||||||
|
|
||||||
|
// Don't render if field is not visible
|
||||||
|
if (!fieldState.isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip layout fields (Section Break, Column Break, Tab Break)
|
||||||
|
if (['Section Break', 'Column Break', 'Tab Break', 'HTML'].includes(fieldConfig.fieldtype)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = disabled || fieldState.isReadOnly;
|
||||||
|
const isRequired = fieldState.isMandatory;
|
||||||
|
const inputType = getInputType(fieldConfig.fieldtype);
|
||||||
|
|
||||||
|
const labelClasses = compact
|
||||||
|
? 'block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5'
|
||||||
|
: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
||||||
|
|
||||||
|
const inputClasses = compact
|
||||||
|
? `w-full px-2 py-1 text-xs border rounded focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white ${
|
||||||
|
isDisabled ? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed' : ''
|
||||||
|
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`
|
||||||
|
: `w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white ${
|
||||||
|
isDisabled ? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed' : ''
|
||||||
|
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`;
|
||||||
|
|
||||||
|
const handleChange = (newValue: any) => {
|
||||||
|
onChange(fieldConfig.fieldname, newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render based on field type
|
||||||
|
const renderField = () => {
|
||||||
|
switch (fieldConfig.fieldtype) {
|
||||||
|
case 'Link':
|
||||||
|
return (
|
||||||
|
<LinkField
|
||||||
|
label={fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
doctype={fieldConfig.options || ''}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isDisabled}
|
||||||
|
compact={compact}
|
||||||
|
placeholder={`Select ${fieldConfig.label || fieldConfig.fieldname}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Select':
|
||||||
|
const options = parseSelectOptions(fieldConfig.options);
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={inputClasses}
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
{fieldConfig.description && !error && (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-xs mt-1">{fieldConfig.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Check':
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === 1 || value === true}
|
||||||
|
onChange={(e) => handleChange(e.target.checked ? 1 : 0)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<label className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Date':
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Datetime':
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={value ? value.replace(' ', 'T').substring(0, 16) : ''}
|
||||||
|
onChange={(e) => handleChange(e.target.value.replace('T', ' '))}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Int':
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => handleChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={inputClasses}
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Float':
|
||||||
|
case 'Currency':
|
||||||
|
case 'Percent':
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => handleChange(e.target.value ? parseFloat(e.target.value) : null)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={inputClasses}
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Small Text':
|
||||||
|
case 'Text':
|
||||||
|
case 'Long Text':
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={inputClasses}
|
||||||
|
rows={fieldConfig.fieldtype === 'Long Text' ? 6 : 3}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Read Only':
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
</label>
|
||||||
|
<div className={`${inputClasses} bg-gray-50 dark:bg-gray-800`}>
|
||||||
|
{value || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Attach':
|
||||||
|
case 'Attach Image':
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
{value && (
|
||||||
|
<div className="mb-2">
|
||||||
|
{fieldConfig.fieldtype === 'Attach Image' && value ? (
|
||||||
|
<img src={value} alt={fieldConfig.label} className="w-24 h-24 object-cover rounded" />
|
||||||
|
) : (
|
||||||
|
<a href={value} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline text-sm">
|
||||||
|
{value.split('/').pop()}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => {
|
||||||
|
// Handle file upload - you may need to implement actual upload logic
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// For now, just store the file name
|
||||||
|
handleChange(file.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Data':
|
||||||
|
case 'Password':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{fieldConfig.label || fieldConfig.fieldname}
|
||||||
|
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={fieldConfig.fieldtype === 'Password' ? 'password' : 'text'}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={inputClasses}
|
||||||
|
placeholder={fieldConfig.description || ''}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderField();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DynamicField;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicForm Component
|
||||||
|
* Renders a complete form based on DocType field configuration
|
||||||
|
*/
|
||||||
|
interface DynamicFormProps {
|
||||||
|
fields: FieldConfig[];
|
||||||
|
doc: Record<string, any>;
|
||||||
|
onChange: (fieldname: string, value: any) => void;
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
disabled?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
columns?: 1 | 2 | 3 | 4;
|
||||||
|
excludeFields?: string[];
|
||||||
|
includeFields?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DynamicForm: React.FC<DynamicFormProps> = ({
|
||||||
|
fields,
|
||||||
|
doc,
|
||||||
|
onChange,
|
||||||
|
errors = {},
|
||||||
|
disabled = false,
|
||||||
|
compact = false,
|
||||||
|
columns = 2,
|
||||||
|
excludeFields = [],
|
||||||
|
includeFields
|
||||||
|
}) => {
|
||||||
|
// Filter and sort fields
|
||||||
|
const visibleFields = useMemo(() => {
|
||||||
|
let filtered = fields.filter(f => {
|
||||||
|
// Skip layout fields
|
||||||
|
if (['Section Break', 'Column Break', 'Tab Break'].includes(f.fieldtype)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply exclude filter
|
||||||
|
if (excludeFields.includes(f.fieldname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply include filter if specified
|
||||||
|
if (includeFields && !includeFields.includes(f.fieldname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check visibility
|
||||||
|
const state = evaluateFieldState(f, doc);
|
||||||
|
return state.isVisible;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [fields, doc, excludeFields, includeFields]);
|
||||||
|
|
||||||
|
const gridClass = {
|
||||||
|
1: 'grid-cols-1',
|
||||||
|
2: 'grid-cols-1 md:grid-cols-2',
|
||||||
|
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||||
|
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
|
||||||
|
}[columns];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid ${gridClass} gap-4`}>
|
||||||
|
{visibleFields.map(field => (
|
||||||
|
<DynamicField
|
||||||
|
key={field.fieldname}
|
||||||
|
fieldConfig={field}
|
||||||
|
value={doc[field.fieldname]}
|
||||||
|
onChange={onChange}
|
||||||
|
doc={doc}
|
||||||
|
disabled={disabled}
|
||||||
|
compact={compact}
|
||||||
|
error={errors[field.fieldname]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
151
pm_app/src/components/Header.tsx
Normal file
151
pm_app/src/components/Header.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Moon, Sun, Languages, LogOut, Menu, UserCircle } from 'lucide-react';
|
||||||
|
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
userEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<HeaderProps> = () => {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { language, changeLanguage } = useLanguage();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { openMobileSidebar } = useSidebarLayout();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [userFullName, setUserFullName] = useState<string>('');
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserFullName = async () => {
|
||||||
|
try {
|
||||||
|
const userResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const userData = await userResponse.json();
|
||||||
|
const email = userData.message;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const fullNameResponse = await fetch(
|
||||||
|
`/api/resource/User/${encodeURIComponent(email)}?fields=["full_name"]`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const fullNameData = await fullNameResponse.json();
|
||||||
|
if (fullNameData.data?.full_name) {
|
||||||
|
setUserFullName(fullNameData.data.full_name);
|
||||||
|
} else {
|
||||||
|
setUserFullName(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching user full name:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserFullName();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('sid');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csrfToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('X-Frappe-CSRF-Token='))
|
||||||
|
?.split('=')[1] || '';
|
||||||
|
|
||||||
|
await fetch('/api/method/frappe.auth.logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Frappe-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetch('/?cmd=web_logout', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err);
|
||||||
|
} finally {
|
||||||
|
window.location.href = '/project_management/login';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 flex items-center justify-between gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openMobileSidebar}
|
||||||
|
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300 -ms-1"
|
||||||
|
aria-label={t('common.menu', { defaultValue: 'Open menu' })}
|
||||||
|
title={t('common.menu', { defaultValue: 'Menu' })}
|
||||||
|
>
|
||||||
|
<Menu size={22} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 lg:flex-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{userFullName && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/user-profile')}
|
||||||
|
onMouseEnter={() => setShowTooltip(true)}
|
||||||
|
onMouseLeave={() => setShowTooltip(false)}
|
||||||
|
className="p-2 rounded-lg bg-[#7911cc] hover:bg-[#6a0fb5] transition-colors text-white"
|
||||||
|
title={userFullName}
|
||||||
|
>
|
||||||
|
<UserCircle size={20} />
|
||||||
|
</button>
|
||||||
|
{showTooltip && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-50 px-3 py-1.5 bg-gray-800 dark:bg-gray-700 text-white text-xs rounded-lg whitespace-nowrap shadow-lg">
|
||||||
|
{userFullName}
|
||||||
|
<div className="absolute -top-1 right-3 w-2 h-2 bg-gray-800 dark:bg-gray-700 rotate-45" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => changeLanguage(language === 'en' ? 'ar' : 'en')}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
|
||||||
|
title={t('common.language')}
|
||||||
|
>
|
||||||
|
<Languages size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
|
||||||
|
title={theme === 'light' ? t('common.darkMode') : t('common.lightMode')}
|
||||||
|
>
|
||||||
|
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400"
|
||||||
|
title={t('common.logout')}
|
||||||
|
>
|
||||||
|
<LogOut size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
478
pm_app/src/components/LinkField.tsx
Normal file
478
pm_app/src/components/LinkField.tsx
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaPlus } from 'react-icons/fa';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
import { supportsQuickCreate } from '../components/QuickCreateConfig';
|
||||||
|
import { hasCreatePermission } from '../services/permissionService';
|
||||||
|
import QuickCreateModal from '../components/QuickCreateModal';
|
||||||
|
|
||||||
|
interface LinkFieldProps {
|
||||||
|
label: string;
|
||||||
|
doctype: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** When true, only the input is rendered (use an outer <label> / FL). */
|
||||||
|
hideLabel?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Frappe filter rows `[['DocType', 'field', 'op', value]]` or legacy dict form */
|
||||||
|
filters?: any;
|
||||||
|
compact?: boolean;
|
||||||
|
usePortal?: boolean;
|
||||||
|
// New props for QuickCreate functionality
|
||||||
|
allowQuickCreate?: boolean; // Enable/disable quick create (default: false)
|
||||||
|
onQuickCreateSuccess?: (newRecord: any) => void; // Callback after quick create
|
||||||
|
quickCreateInitialValues?: Record<string, any>; // Initial values for quick create form
|
||||||
|
query?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable empty object to avoid re-renders
|
||||||
|
const EMPTY_FILTERS: Record<string, any> = {};
|
||||||
|
|
||||||
|
const LinkField: React.FC<LinkFieldProps> = ({
|
||||||
|
label,
|
||||||
|
doctype,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
hideLabel = false,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
filters,
|
||||||
|
compact = false,
|
||||||
|
usePortal = true,
|
||||||
|
// QuickCreate props with defaults
|
||||||
|
allowQuickCreate = false, // Default to false - must explicitly enable per field
|
||||||
|
onQuickCreateSuccess,
|
||||||
|
quickCreateInitialValues = {},
|
||||||
|
query,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchResults, setSearchResults] = useState<{ value: string; description?: string }[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [isDropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 });
|
||||||
|
|
||||||
|
// QuickCreate modal state
|
||||||
|
const [showQuickCreate, setShowQuickCreate] = useState(false);
|
||||||
|
|
||||||
|
// Permission state for QuickCreate
|
||||||
|
// null = not checked yet, true = allowed, false = denied
|
||||||
|
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastSearchRef = useRef<string>('');
|
||||||
|
const hasLoadedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
// Use stable empty object if filters not provided
|
||||||
|
const stableFilters = filters || EMPTY_FILTERS;
|
||||||
|
|
||||||
|
// Stringify filters for comparison (avoid object reference issues)
|
||||||
|
const filtersKey = useMemo(() => JSON.stringify(stableFilters), [stableFilters]);
|
||||||
|
|
||||||
|
// Check if doctype has QuickCreate config
|
||||||
|
const hasQuickCreateConfig = useMemo(() => {
|
||||||
|
const supported = supportsQuickCreate(doctype);
|
||||||
|
console.log(`[LinkField] ${doctype} hasQuickCreateConfig: ${supported}`);
|
||||||
|
return supported;
|
||||||
|
}, [doctype]);
|
||||||
|
|
||||||
|
// Check permission ONLY when allowQuickCreate is enabled AND doctype has config
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset state when doctype or allowQuickCreate changes
|
||||||
|
setHasPermission(null);
|
||||||
|
|
||||||
|
// Only check permission if allowQuickCreate is true AND doctype has config
|
||||||
|
if (allowQuickCreate && hasQuickCreateConfig) {
|
||||||
|
console.log(`[LinkField] Checking permission for ${doctype}...`);
|
||||||
|
|
||||||
|
hasCreatePermission(doctype)
|
||||||
|
.then((result) => {
|
||||||
|
console.log(`[LinkField] Permission for ${doctype}: ${result}`);
|
||||||
|
setHasPermission(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(`[LinkField] Permission check failed for ${doctype}:`, err);
|
||||||
|
setHasPermission(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If allowQuickCreate is false or no config, don't show button
|
||||||
|
setHasPermission(false);
|
||||||
|
|
||||||
|
if (allowQuickCreate && !hasQuickCreateConfig) {
|
||||||
|
console.warn(`[LinkField] ${doctype}: allowQuickCreate=true but no config in QuickCreateConfig.ts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [allowQuickCreate, doctype, hasQuickCreateConfig]);
|
||||||
|
|
||||||
|
// Final check: show button only if ALL conditions are met:
|
||||||
|
// 1. allowQuickCreate={true} is set on the field
|
||||||
|
// 2. Doctype has config in QuickCreateConfig.ts
|
||||||
|
// 3. Permission check passed (hasPermission === true)
|
||||||
|
const canQuickCreate = useMemo(() => {
|
||||||
|
const result = allowQuickCreate && hasQuickCreateConfig && hasPermission === true;
|
||||||
|
console.log(`[LinkField] canQuickCreate for ${doctype}: ${result}`, {
|
||||||
|
allowQuickCreate,
|
||||||
|
hasQuickCreateConfig,
|
||||||
|
hasPermission
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [allowQuickCreate, hasQuickCreateConfig, hasPermission, doctype]);
|
||||||
|
|
||||||
|
/// Fetch link options from ERPNext with filters
|
||||||
|
const searchLink = useCallback(async (text: string = '', force: boolean = false) => {
|
||||||
|
// Prevent duplicate calls for the same search text
|
||||||
|
const searchKey = `${text}-${filtersKey}-${query || ''}`;
|
||||||
|
if (!force && lastSearchRef.current === searchKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSearchRef.current = searchKey;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
let response: { value: string; description?: string }[] | null = null;
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
// Use custom query method
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
txt: text,
|
||||||
|
doctype: doctype,
|
||||||
|
searchfield: 'name',
|
||||||
|
start: '0',
|
||||||
|
page_len: '50',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add filters if provided
|
||||||
|
if (stableFilters && Object.keys(stableFilters).length > 0) {
|
||||||
|
params.append('filters', JSON.stringify(stableFilters));
|
||||||
|
}
|
||||||
|
|
||||||
|
const customResponse = await apiService.apiCall<any>(
|
||||||
|
`/api/method/${query}?${params.toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom query returns array of arrays: [[value, description], ...]
|
||||||
|
// Convert to expected format
|
||||||
|
if (Array.isArray(customResponse)) {
|
||||||
|
response = customResponse.map((item: any) => {
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
return { value: item[0], description: item[1] || undefined };
|
||||||
|
}
|
||||||
|
return { value: item.value || item.name || item, description: item.description };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use standard Frappe search_link
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
doctype,
|
||||||
|
txt: text,
|
||||||
|
page_length: '50',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add filters if provided
|
||||||
|
if (stableFilters && Object.keys(stableFilters).length > 0) {
|
||||||
|
params.append('filters', JSON.stringify(stableFilters));
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await apiService.apiCall<{ value: string; description?: string }[]>(
|
||||||
|
`/api/method/frappe.desk.search.search_link?${params.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchResults(response || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${doctype} links:`, error);
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [doctype, filtersKey, stableFilters, query]);
|
||||||
|
|
||||||
|
// Debounced search for typing
|
||||||
|
const debouncedSearch = useCallback((text: string) => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
searchLink(text);
|
||||||
|
}, 300);
|
||||||
|
}, [searchLink]);
|
||||||
|
|
||||||
|
// Fetch default options ONLY when dropdown first opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDropdownOpen && !hasLoadedRef.current) {
|
||||||
|
hasLoadedRef.current = true;
|
||||||
|
searchLink(searchText || '', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the loaded flag when dropdown closes
|
||||||
|
if (!isDropdownOpen) {
|
||||||
|
hasLoadedRef.current = false;
|
||||||
|
lastSearchRef.current = '';
|
||||||
|
}
|
||||||
|
}, [isDropdownOpen]); // Only depend on isDropdownOpen
|
||||||
|
|
||||||
|
// Cleanup debounce on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Calculate dropdown position for portal rendering
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
if (usePortal && inputRef.current) {
|
||||||
|
const rect = inputRef.current.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
width: rect.width
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [usePortal]);
|
||||||
|
|
||||||
|
// Update position when dropdown opens or on scroll/resize
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDropdownOpen && usePortal) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
|
||||||
|
const handleUpdate = () => updateDropdownPosition();
|
||||||
|
window.addEventListener('scroll', handleUpdate, true);
|
||||||
|
window.addEventListener('resize', handleUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleUpdate, true);
|
||||||
|
window.removeEventListener('resize', handleUpdate);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isDropdownOpen, usePortal, updateDropdownPosition]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
const clickedOutsideContainer = containerRef.current && !containerRef.current.contains(target);
|
||||||
|
const clickedOutsideDropdown = usePortal && dropdownRef.current && !dropdownRef.current.contains(target);
|
||||||
|
|
||||||
|
// Close if clicked outside both container and dropdown (when using portal)
|
||||||
|
if (usePortal) {
|
||||||
|
if (clickedOutsideContainer && clickedOutsideDropdown) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (clickedOutsideContainer) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [usePortal]);
|
||||||
|
|
||||||
|
// Handle selecting an item from dropdown
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
onChange(selectedValue);
|
||||||
|
setSearchText('');
|
||||||
|
setDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clearing the field
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange('');
|
||||||
|
setSearchText('');
|
||||||
|
setDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle opening QuickCreate modal
|
||||||
|
const handleOpenQuickCreate = () => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setSearchText('');
|
||||||
|
setShowQuickCreate(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle QuickCreate success
|
||||||
|
const handleQuickCreateSuccess = (newRecord: any) => {
|
||||||
|
// Get the name/value from the new record
|
||||||
|
const newValue = newRecord.name || newRecord[Object.keys(newRecord)[0]];
|
||||||
|
handleSelect(newValue);
|
||||||
|
|
||||||
|
// Call external callback if provided
|
||||||
|
if (onQuickCreateSuccess) {
|
||||||
|
onQuickCreateSuccess(newRecord);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render dropdown content
|
||||||
|
const renderDropdown = () => {
|
||||||
|
const dropdownClasses = `bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
|
||||||
|
rounded-md w-full shadow-lg ${compact ? 'mt-0.5' : 'mt-1'}`;
|
||||||
|
|
||||||
|
const positionStyle = usePortal ? {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
top: `${dropdownPosition.top}px`,
|
||||||
|
left: `${dropdownPosition.left}px`,
|
||||||
|
width: `${dropdownPosition.width}px`,
|
||||||
|
zIndex: 1050,
|
||||||
|
marginTop: compact ? '2px' : '4px'
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
if (!isDropdownOpen || disabled) return null;
|
||||||
|
|
||||||
|
const dropdownContent = (
|
||||||
|
<div ref={dropdownRef}>
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} text-center text-gray-500 dark:text-gray-400
|
||||||
|
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}
|
||||||
|
style={positionStyle}>
|
||||||
|
<span className="inline-block animate-spin mr-2">⏳</span>
|
||||||
|
{t('linkField.loading')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results list with QuickCreate option */}
|
||||||
|
{!isLoading && (
|
||||||
|
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} overflow-hidden`}
|
||||||
|
style={positionStyle}>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
<ul className={`overflow-auto ${compact ? 'max-h-36' : 'max-h-48'}`}>
|
||||||
|
{searchResults.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
onClick={() => handleSelect(item.value)}
|
||||||
|
className={`cursor-pointer text-gray-900 dark:text-gray-100
|
||||||
|
hover:bg-blue-500 dark:hover:bg-blue-600 hover:text-white
|
||||||
|
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm'}
|
||||||
|
${value === item.value ? 'bg-blue-50 dark:bg-blue-700 font-semibold' : ''}`}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
{item.description && (
|
||||||
|
<span className={`text-gray-600 dark:text-gray-300 ml-2
|
||||||
|
${compact ? 'text-[9px] ml-1' : 'text-xs ml-2'}`}>
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className={`text-center text-gray-500 dark:text-gray-400
|
||||||
|
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}>
|
||||||
|
{t('linkField.noResultsFound')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QuickCreate Button - Only shows if all conditions are met */}
|
||||||
|
{canQuickCreate && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700" />
|
||||||
|
<div
|
||||||
|
onClick={handleOpenQuickCreate}
|
||||||
|
className={`cursor-pointer flex items-center gap-2
|
||||||
|
text-green-600 dark:text-green-400
|
||||||
|
hover:bg-green-50 dark:hover:bg-green-900/20
|
||||||
|
hover:text-green-700 dark:hover:text-green-300
|
||||||
|
transition-colors
|
||||||
|
${compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2.5 text-sm'}`}
|
||||||
|
>
|
||||||
|
<FaPlus size={compact ? 10 : 12} />
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('linkField.createNewDoctype', { doctype: doctype.replace(/_/g, ' ') })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return usePortal ? createPortal(dropdownContent, document.body) : dropdownContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative w-full ${compact ? 'mb-2' : hideLabel ? 'mb-0' : 'mb-4'}`}
|
||||||
|
>
|
||||||
|
{!hideLabel && (
|
||||||
|
<label className={`block font-medium text-gray-700 dark:text-gray-300 ${compact ? 'text-[10px] mb-0.5' : 'text-sm mb-1'}`}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={isDropdownOpen ? searchText : value}
|
||||||
|
placeholder={placeholder || t('linkField.selectLabel', { label })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full border border-gray-300 dark:border-gray-600 rounded-md
|
||||||
|
focus:outline-none disabled:bg-gray-100 dark:disabled:bg-gray-700
|
||||||
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
|
||||||
|
${compact
|
||||||
|
? 'px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 rounded'
|
||||||
|
: 'px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500'
|
||||||
|
}
|
||||||
|
${value ? (compact ? 'pr-5' : 'pr-8') : ''}`}
|
||||||
|
onFocus={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
setDropdownOpen(true);
|
||||||
|
setSearchText('');
|
||||||
|
if (usePortal) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
const text = e.target.value;
|
||||||
|
setSearchText(text);
|
||||||
|
debouncedSearch(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clear button */}
|
||||||
|
{value && !disabled && !isDropdownOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
|
||||||
|
${compact ? 'right-1 text-xs' : 'right-2 text-sm'}`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Render dropdown */}
|
||||||
|
{renderDropdown()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QuickCreate Modal */}
|
||||||
|
<QuickCreateModal
|
||||||
|
doctype={doctype}
|
||||||
|
isOpen={showQuickCreate}
|
||||||
|
onClose={() => setShowQuickCreate(false)}
|
||||||
|
onSuccess={handleQuickCreateSuccess}
|
||||||
|
initialValues={quickCreateInitialValues}
|
||||||
|
parentFilters={stableFilters}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkField;
|
||||||
151
pm_app/src/components/ListPagination.tsx
Normal file
151
pm_app/src/components/ListPagination.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export interface ListPaginationProps {
|
||||||
|
/** Current page (1-based for display) */
|
||||||
|
currentPage: number;
|
||||||
|
/** Total number of items (optional - when missing we don't show "of N" or page numbers) */
|
||||||
|
totalCount?: number;
|
||||||
|
/** Page size (limit per page) */
|
||||||
|
pageSize: number;
|
||||||
|
/** Whether there is a next page (when totalCount is not available) */
|
||||||
|
hasMore?: boolean;
|
||||||
|
/** Label for the list, e.g. "items", "issues" */
|
||||||
|
itemLabel?: string;
|
||||||
|
/** Callback when user changes page. Receives 1-based page number. */
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
/** Optional class for the container */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable list pagination: Previous/Next, optional page number buttons,
|
||||||
|
* "Go to page" input, and "Showing X to Y of Z" text.
|
||||||
|
* Page is 1-based in this component (URLs and display).
|
||||||
|
*/
|
||||||
|
const ListPagination: React.FC<ListPaginationProps> = ({
|
||||||
|
currentPage,
|
||||||
|
totalCount = 0,
|
||||||
|
pageSize,
|
||||||
|
hasMore = false,
|
||||||
|
itemLabel,
|
||||||
|
onPageChange,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const displayLabel = itemLabel ?? t('listPages.results');
|
||||||
|
const totalPages = totalCount > 0 ? Math.max(1, Math.ceil(totalCount / pageSize)) : 0;
|
||||||
|
const hasTotal = totalCount > 0;
|
||||||
|
const start = (currentPage - 1) * pageSize + 1;
|
||||||
|
const end = hasTotal
|
||||||
|
? Math.min(currentPage * pageSize, totalCount)
|
||||||
|
: currentPage * pageSize;
|
||||||
|
|
||||||
|
const [goToInput, setGoToInput] = useState('');
|
||||||
|
|
||||||
|
const handleGoToSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const num = parseInt(goToInput.trim(), 10);
|
||||||
|
if (!Number.isNaN(num) && num >= 1) {
|
||||||
|
const target = hasTotal ? Math.min(num, totalPages) : num;
|
||||||
|
onPageChange(target);
|
||||||
|
setGoToInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Page numbers to show: first, last, and window around current (e.g. 1 ... 4 5 6 ... 20)
|
||||||
|
const getPageNumbers = (): (number | 'ellipsis')[] => {
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
const pages: (number | 'ellipsis')[] = [];
|
||||||
|
pages.push(1);
|
||||||
|
if (currentPage > 3) pages.push('ellipsis');
|
||||||
|
for (let p = Math.max(2, currentPage - 1); p <= Math.min(totalPages - 1, currentPage + 1); p++) {
|
||||||
|
if (!pages.includes(p)) pages.push(p);
|
||||||
|
}
|
||||||
|
if (currentPage < totalPages - 2) pages.push('ellipsis');
|
||||||
|
if (totalPages > 1) pages.push(totalPages);
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPagination = hasMore || currentPage > 1 || (hasTotal && totalPages > 1);
|
||||||
|
|
||||||
|
if (!showPagination) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 ${className}`}>
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{hasTotal
|
||||||
|
? t('pagination.showingToOf', { start, end, total: totalCount, label: displayLabel })
|
||||||
|
: t('pagination.showingTo', { start, end, label: displayLabel })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('pagination.previous')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasTotal && totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{getPageNumbers().map((p, i) =>
|
||||||
|
p === 'ellipsis' ? (
|
||||||
|
<span key={`e-${i}`} className="px-2 text-gray-500 dark:text-gray-400">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
className={`min-w-[2rem] px-2 py-1 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
p === currentPage
|
||||||
|
? 'bg-blue-600 text-white border border-blue-600'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={hasTotal ? currentPage >= totalPages : !hasMore}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('pagination.next')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form onSubmit={handleGoToSubmit} className="flex items-center gap-1 ml-2">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">{t('pagination.goTo')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={hasTotal ? totalPages : undefined}
|
||||||
|
value={goToInput}
|
||||||
|
onChange={(e) => setGoToInput(e.target.value)}
|
||||||
|
placeholder={hasTotal ? `1-${totalPages}` : t('pagination.page')}
|
||||||
|
className="w-14 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-2 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{t('pagination.go')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListPagination;
|
||||||
455
pm_app/src/components/QuickCreateConfig.tsx
Normal file
455
pm_app/src/components/QuickCreateConfig.tsx
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* QuickCreate Configuration
|
||||||
|
*
|
||||||
|
* This file defines the configuration for quick-creating new records
|
||||||
|
* from LinkField dropdowns. Add new doctypes as needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface QuickCreateFieldConfig {
|
||||||
|
fieldname: string;
|
||||||
|
label: string;
|
||||||
|
fieldtype: 'Data' | 'Text' | 'Select' | 'Link' | 'Check' | 'Int' | 'Float' | 'Date' | 'Datetime';
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: string[] | { label: string; value: string }[]; // For Select fieldtype
|
||||||
|
linkDoctype?: string; // For Link fieldtype
|
||||||
|
linkFilters?: Record<string, any>; // Filters for Link fieldtype
|
||||||
|
defaultValue?: any;
|
||||||
|
description?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
dependsOn?: string; // Field name this field depends on
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickCreateDoctypeConfig {
|
||||||
|
doctype: string;
|
||||||
|
title: string; // Display title for the modal
|
||||||
|
fields: QuickCreateFieldConfig[];
|
||||||
|
titleField?: string; // The main field that represents the record name (defaults to 'name')
|
||||||
|
afterCreate?: (newRecord: any) => void; // Callback after successful creation
|
||||||
|
validateBeforeCreate?: (data: Record<string, any>) => string | null; // Return error message or null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for all doctypes that support quick creation
|
||||||
|
* Add new doctypes here as needed
|
||||||
|
*/
|
||||||
|
export const QUICK_CREATE_CONFIG: Record<string, QuickCreateDoctypeConfig> = {
|
||||||
|
// Location Doctype
|
||||||
|
'Location': {
|
||||||
|
doctype: 'Location',
|
||||||
|
title: 'Create New Location',
|
||||||
|
titleField: 'location_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'location_name',
|
||||||
|
label: 'Location Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter location name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'parent_location',
|
||||||
|
label: 'Parent Location',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Location',
|
||||||
|
placeholder: 'Select parent location (optional)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'is_group',
|
||||||
|
label: 'Is Group',
|
||||||
|
fieldtype: 'Check',
|
||||||
|
defaultValue: 0,
|
||||||
|
description: 'Check if this location contains sub-locations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'latitude',
|
||||||
|
label: 'Latitude',
|
||||||
|
fieldtype: 'Float',
|
||||||
|
placeholder: 'e.g., 24.7136',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'longitude',
|
||||||
|
label: 'Longitude',
|
||||||
|
fieldtype: 'Float',
|
||||||
|
placeholder: 'e.g., 46.6753',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Room Doctype
|
||||||
|
'Room': {
|
||||||
|
doctype: 'Room',
|
||||||
|
title: 'Create New Room',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'room',
|
||||||
|
label: 'Room Name/Number',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter room name or number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'building',
|
||||||
|
label: 'Building',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Building',
|
||||||
|
placeholder: 'Select building',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'location_name',
|
||||||
|
label: 'Location Name',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Location',
|
||||||
|
placeholder: 'Select Location',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'department',
|
||||||
|
label: 'Department',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Department',
|
||||||
|
placeholder: 'Select department',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Building Doctype
|
||||||
|
'Building': {
|
||||||
|
doctype: 'Building',
|
||||||
|
title: 'Create New Building',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'building',
|
||||||
|
label: 'Building Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter building name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Extension Directory Doctype
|
||||||
|
'Extension Directory': {
|
||||||
|
doctype: 'Extension Directory',
|
||||||
|
title: 'Create New Extension',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'extension_number',
|
||||||
|
label: 'Extension Number',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter extension number',
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Department Doctype
|
||||||
|
'Department': {
|
||||||
|
doctype: 'Department',
|
||||||
|
title: 'Create New Department',
|
||||||
|
titleField: 'department_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'department_name',
|
||||||
|
label: 'Department Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter department name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'company',
|
||||||
|
label: 'Company',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Company',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Select company',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'parent_department',
|
||||||
|
label: 'Parent Department',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Department',
|
||||||
|
placeholder: 'Select parent department',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'is_group',
|
||||||
|
label: 'Is Group',
|
||||||
|
fieldtype: 'Check',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Issue Type (Work Order Type)
|
||||||
|
'Issue Type': {
|
||||||
|
doctype: 'Issue Type',
|
||||||
|
title: 'Create New Issue Type',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: '__newname',
|
||||||
|
label: 'Issue Type Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter issue type name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
fieldtype: 'Text',
|
||||||
|
placeholder: 'Enter description',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manufacturer
|
||||||
|
'Manufacturer': {
|
||||||
|
doctype: 'Manufacturer',
|
||||||
|
title: 'Create New Manufacturer',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'short_name',
|
||||||
|
label: 'Manufacturer Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter manufacturer name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'full_name',
|
||||||
|
label: 'Full Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
placeholder: 'Enter full company name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'website',
|
||||||
|
label: 'Website',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'country',
|
||||||
|
label: 'Country',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Country',
|
||||||
|
placeholder: 'Select country',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Supplier
|
||||||
|
'Supplier': {
|
||||||
|
doctype: 'Supplier',
|
||||||
|
title: 'Create New Supplier',
|
||||||
|
titleField: 'supplier_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'supplier_name',
|
||||||
|
label: 'Supplier Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter supplier name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'supplier_group',
|
||||||
|
label: 'Supplier Group',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Supplier Group',
|
||||||
|
placeholder: 'Select supplier group',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'supplier_type',
|
||||||
|
label: 'Supplier Type',
|
||||||
|
fieldtype: 'Select',
|
||||||
|
options: ['Company', 'Individual'],
|
||||||
|
defaultValue: 'Company',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'country',
|
||||||
|
label: 'Country',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Country',
|
||||||
|
placeholder: 'Select country',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Warehouse
|
||||||
|
'Warehouse': {
|
||||||
|
doctype: 'Warehouse',
|
||||||
|
title: 'Create New Warehouse',
|
||||||
|
titleField: 'warehouse_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'warehouse_name',
|
||||||
|
label: 'Warehouse Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter warehouse name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'company',
|
||||||
|
label: 'Company',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Company',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Select company',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'parent_warehouse',
|
||||||
|
label: 'Parent Warehouse',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Warehouse',
|
||||||
|
placeholder: 'Select parent warehouse',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'is_group',
|
||||||
|
label: 'Is Group',
|
||||||
|
fieldtype: 'Check',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Item
|
||||||
|
'Item': {
|
||||||
|
doctype: 'Item',
|
||||||
|
title: 'Create New Item',
|
||||||
|
titleField: 'item_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'item_code',
|
||||||
|
label: 'Item Code',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter item code',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'item_name',
|
||||||
|
label: 'Item Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter item name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'item_group',
|
||||||
|
label: 'Item Group',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Item Group',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Select item group',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'stock_uom',
|
||||||
|
label: 'Default Unit of Measure',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'UOM',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'Nos',
|
||||||
|
placeholder: 'Select UOM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'is_stock_item',
|
||||||
|
label: 'Maintain Stock',
|
||||||
|
fieldtype: 'Check',
|
||||||
|
defaultValue: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
fieldtype: 'Text',
|
||||||
|
placeholder: 'Enter item description',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Asset
|
||||||
|
'Asset': {
|
||||||
|
doctype: 'Asset',
|
||||||
|
title: 'Create New Asset',
|
||||||
|
titleField: 'asset_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'asset_name',
|
||||||
|
label: 'Asset Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter asset name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'item_code',
|
||||||
|
label: 'Item Code',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Item',
|
||||||
|
required: true,
|
||||||
|
linkFilters: { is_fixed_asset: 1 },
|
||||||
|
placeholder: 'Select item',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'company',
|
||||||
|
label: 'Company',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Company',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Select company',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'location',
|
||||||
|
label: 'Location',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Location',
|
||||||
|
placeholder: 'Select location',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'custodian',
|
||||||
|
label: 'Custodian',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Employee',
|
||||||
|
placeholder: 'Select custodian',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Technical Department
|
||||||
|
'Technical Department': {
|
||||||
|
doctype: 'Technical Department',
|
||||||
|
title: 'Create New Technical Department',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'department',
|
||||||
|
label: 'Department Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter technical department name',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get configuration for a doctype
|
||||||
|
*/
|
||||||
|
export const getQuickCreateConfig = (doctype: string): QuickCreateDoctypeConfig | null => {
|
||||||
|
return QUICK_CREATE_CONFIG[doctype] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a doctype supports quick creation
|
||||||
|
*/
|
||||||
|
export const supportsQuickCreate = (doctype: string): boolean => {
|
||||||
|
return doctype in QUICK_CREATE_CONFIG;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or update a doctype configuration at runtime
|
||||||
|
*/
|
||||||
|
export const registerQuickCreateConfig = (config: QuickCreateDoctypeConfig): void => {
|
||||||
|
QUICK_CREATE_CONFIG[config.doctype] = config;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QUICK_CREATE_CONFIG;
|
||||||
602
pm_app/src/components/QuickCreateModal.tsx
Normal file
602
pm_app/src/components/QuickCreateModal.tsx
Normal file
@ -0,0 +1,602 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
FaTimes,
|
||||||
|
FaPlus,
|
||||||
|
FaSpinner,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaTimesCircle,
|
||||||
|
FaExclamationTriangle,
|
||||||
|
FaSearch
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import {
|
||||||
|
type QuickCreateDoctypeConfig,
|
||||||
|
type QuickCreateFieldConfig,
|
||||||
|
getQuickCreateConfig
|
||||||
|
} from './QuickCreateConfig';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
// Simple Link Input component for use inside modal (avoids circular dependency)
|
||||||
|
interface SimpleLinkInputProps {
|
||||||
|
doctype: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SimpleLinkInput: React.FC<SimpleLinkInputProps> = ({
|
||||||
|
doctype,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = 'Search...',
|
||||||
|
filters = {},
|
||||||
|
}) => {
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [results, setResults] = useState<{ value: string; description?: string }[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Search function
|
||||||
|
const searchLink = useCallback(async (text: string = '') => {
|
||||||
|
if (!doctype) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
doctype,
|
||||||
|
txt: text,
|
||||||
|
page_length: '20',
|
||||||
|
});
|
||||||
|
if (filters && Object.keys(filters).length > 0) {
|
||||||
|
params.append('filters', JSON.stringify(filters));
|
||||||
|
}
|
||||||
|
const response = await apiService.apiCall<{ value: string; description?: string }[]>(
|
||||||
|
`/api/method/frappe.desk.search.search_link?${params.toString()}`
|
||||||
|
);
|
||||||
|
setResults(response || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${doctype} links:`, error);
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [doctype, filters]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => searchLink(searchText), 300);
|
||||||
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
|
}, [searchText, isOpen, searchLink]);
|
||||||
|
|
||||||
|
// Load on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) searchLink(searchText);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (val: string) => {
|
||||||
|
onChange(val);
|
||||||
|
setSearchText('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange('');
|
||||||
|
setSearchText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={12} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={isOpen ? searchText : value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full pl-9 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||||
|
disabled:bg-gray-100 dark:disabled:bg-gray-700
|
||||||
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-white`}
|
||||||
|
onFocus={() => !disabled && setIsOpen(true)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{value && !disabled && !isOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<FaTimes size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && !disabled && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-40 overflow-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-3 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
<FaSpinner className="animate-spin inline mr-2" size={12} />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : results.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{results.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
onClick={() => handleSelect(item.value)}
|
||||||
|
className={`px-3 py-2 cursor-pointer text-sm hover:bg-blue-500 hover:text-white
|
||||||
|
${value === item.value ? 'bg-blue-50 dark:bg-blue-900/30' : ''}`}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
{item.description && (
|
||||||
|
<span className="text-xs text-gray-500 ml-2">{item.description}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuickCreateModalProps {
|
||||||
|
doctype: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (newRecord: any) => void;
|
||||||
|
initialValues?: Record<string, any>;
|
||||||
|
parentFilters?: Record<string, any>; // Filters to pass down to link fields
|
||||||
|
customConfig?: QuickCreateDoctypeConfig; // Override default config
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuickCreateModal: React.FC<QuickCreateModalProps> = ({
|
||||||
|
doctype,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
initialValues = {},
|
||||||
|
parentFilters = {},
|
||||||
|
customConfig,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [config, setConfig] = useState<QuickCreateDoctypeConfig | null>(null);
|
||||||
|
|
||||||
|
// Get configuration for the doctype
|
||||||
|
useEffect(() => {
|
||||||
|
const doctypeConfig = customConfig || getQuickCreateConfig(doctype);
|
||||||
|
setConfig(doctypeConfig);
|
||||||
|
|
||||||
|
if (doctypeConfig) {
|
||||||
|
// Initialize form data with default values
|
||||||
|
const defaultData: Record<string, any> = {};
|
||||||
|
doctypeConfig.fields.forEach((field) => {
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
defaultData[field.fieldname] = field.defaultValue;
|
||||||
|
} else if (field.fieldtype === 'Check') {
|
||||||
|
defaultData[field.fieldname] = 0;
|
||||||
|
} else {
|
||||||
|
defaultData[field.fieldname] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge with initial values
|
||||||
|
setFormData({ ...defaultData, ...initialValues });
|
||||||
|
}
|
||||||
|
}, [doctype, customConfig, initialValues]);
|
||||||
|
|
||||||
|
// Reset form when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && config) {
|
||||||
|
const defaultData: Record<string, any> = {};
|
||||||
|
config.fields.forEach((field) => {
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
defaultData[field.fieldname] = field.defaultValue;
|
||||||
|
} else if (field.fieldtype === 'Check') {
|
||||||
|
defaultData[field.fieldname] = 0;
|
||||||
|
} else {
|
||||||
|
defaultData[field.fieldname] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFormData({ ...defaultData, ...initialValues });
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
}, [isOpen, config, initialValues]);
|
||||||
|
|
||||||
|
// Handle field change
|
||||||
|
const handleFieldChange = useCallback((fieldname: string, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldname]: value }));
|
||||||
|
// Clear error for this field
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[fieldname];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validateForm = useCallback((): boolean => {
|
||||||
|
if (!config) return false;
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
config.fields.forEach((field) => {
|
||||||
|
if (field.required && !field.hidden) {
|
||||||
|
const value = formData[field.fieldname];
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
newErrors[field.fieldname] = `${field.label} is required`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run custom validation if provided
|
||||||
|
if (config.validateBeforeCreate) {
|
||||||
|
const customError = config.validateBeforeCreate(formData);
|
||||||
|
if (customError) {
|
||||||
|
toast.error(customError, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 4000,
|
||||||
|
icon: <FaExclamationTriangle />,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}, [config, formData]);
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm() || !config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare data for API - only include non-empty fields
|
||||||
|
const dataToSubmit: Record<string, any> = {};
|
||||||
|
Object.entries(formData).forEach(([key, value]) => {
|
||||||
|
if (value !== '' && value !== null && value !== undefined) {
|
||||||
|
dataToSubmit[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make API call to create the record
|
||||||
|
const response = await apiService.apiCall<any>(
|
||||||
|
`/api/resource/${config.doctype}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(dataToSubmit),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response?.data) {
|
||||||
|
const newRecord = response.data;
|
||||||
|
|
||||||
|
toast.success(`${config.title.replace('Create New ', '')} created successfully!`, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaCheckCircle />,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call afterCreate callback if provided
|
||||||
|
if (config.afterCreate) {
|
||||||
|
config.afterCreate(newRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call onSuccess callback with the new record
|
||||||
|
onSuccess(newRecord);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to create record');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating record:', err);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||||
|
|
||||||
|
// Check for duplicate entry error
|
||||||
|
if (errorMessage.includes('Duplicate') || errorMessage.includes('already exists')) {
|
||||||
|
toast.error(`A record with this name already exists. Please use a different name.`, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(`Failed to create: ${errorMessage}`, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a single field based on its type
|
||||||
|
const renderField = (field: QuickCreateFieldConfig) => {
|
||||||
|
if (field.hidden) return null;
|
||||||
|
|
||||||
|
const value = formData[field.fieldname];
|
||||||
|
const error = errors[field.fieldname];
|
||||||
|
const isDisabled = field.readOnly || isSubmitting;
|
||||||
|
|
||||||
|
// Check if field should be shown based on depends_on
|
||||||
|
if (field.dependsOn) {
|
||||||
|
const dependsOnValue = formData[field.dependsOn];
|
||||||
|
if (!dependsOnValue) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseInputClass = `w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||||
|
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||||
|
${isDisabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
||||||
|
text-gray-900 dark:text-white`;
|
||||||
|
|
||||||
|
switch (field.fieldtype) {
|
||||||
|
case 'Data':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Text':
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
rows={3}
|
||||||
|
className={`${baseInputClass} resize-none`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Select':
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
>
|
||||||
|
<option value="">Select {field.label}</option>
|
||||||
|
{(field.options || []).map((option) => {
|
||||||
|
const optionValue = typeof option === 'string' ? option : option.value;
|
||||||
|
const optionLabel = typeof option === 'string' ? option : option.label;
|
||||||
|
return (
|
||||||
|
<option key={optionValue} value={optionValue}>
|
||||||
|
{optionLabel}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Link':
|
||||||
|
return (
|
||||||
|
<SimpleLinkInput
|
||||||
|
doctype={field.linkDoctype || ''}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(val) => handleFieldChange(field.fieldname, val)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
// filters={{ ...field.linkFilters, ...parentFilters }}
|
||||||
|
filters={field.linkFilters || {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Check':
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === 1 || value === true}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.checked ? 1 : 0)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500
|
||||||
|
dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2
|
||||||
|
dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{field.description || field.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Int':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, parseInt(e.target.value) || '')}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
step="1"
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Float':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, parseFloat(e.target.value) || '')}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
step="0.01"
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Date':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Datetime':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !config) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||||
|
<FaPlus className="text-blue-500" size={16} />
|
||||||
|
{config.title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FaTimes size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{config.fields.map((field) => {
|
||||||
|
if (field.hidden) return null;
|
||||||
|
|
||||||
|
// Check depends_on
|
||||||
|
if (field.dependsOn) {
|
||||||
|
const dependsOnValue = formData[field.dependsOn];
|
||||||
|
if (!dependsOnValue) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.fieldname}>
|
||||||
|
{field.fieldtype !== 'Check' && (
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderField(field)}
|
||||||
|
|
||||||
|
{field.description && field.fieldtype !== 'Check' && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{field.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors[field.fieldname] && (
|
||||||
|
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
||||||
|
<FaExclamationTriangle size={10} />
|
||||||
|
{errors[field.fieldname]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin" size={14} />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaPlus size={14} />
|
||||||
|
Create
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuickCreateModal;
|
||||||
183
pm_app/src/components/Sidebar.tsx
Normal file
183
pm_app/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
FolderOpen,
|
||||||
|
LayoutDashboard,
|
||||||
|
List,
|
||||||
|
ClipboardList,
|
||||||
|
Clock,
|
||||||
|
Tags,
|
||||||
|
FileStack,
|
||||||
|
UserCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface SidebarLink {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
userEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLgUp(): boolean {
|
||||||
|
const [lg, setLg] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : false
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(min-width: 1024px)');
|
||||||
|
const onChange = () => setLg(mq.matches);
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
return lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = () => {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [isProjectsExpanded, setIsProjectsExpanded] = useState(true);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isLgUp = useLgUp();
|
||||||
|
const { isRTL } = useLanguage();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mobileSidebarOpen, closeMobileSidebar } = useSidebarLayout();
|
||||||
|
|
||||||
|
const afterNav = () => {
|
||||||
|
if (!isLgUp) closeMobileSidebar();
|
||||||
|
};
|
||||||
|
|
||||||
|
const edgeBorderClass = isRTL
|
||||||
|
? 'border-l border-gray-200 dark:border-gray-700'
|
||||||
|
: 'border-r border-gray-200 dark:border-gray-700';
|
||||||
|
const activeEdgeBorderClass = isRTL ? 'border-r-4 border-white' : 'border-l-4 border-white';
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.BASE_URL || '/assets/project_management/pm_app/';
|
||||||
|
const backgroundImageUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}sidebar-background.jpg`;
|
||||||
|
const logoVersion = '?v=1781183030';
|
||||||
|
|
||||||
|
const mainLinks: SidebarLink[] = [
|
||||||
|
{ id: 'hub', title: t('sidebar.projects', { defaultValue: 'Project Management' }), icon: <FolderOpen size={20} />, path: '/projects' },
|
||||||
|
{ id: 'reports', title: 'Dashboard', icon: <LayoutDashboard size={20} />, path: '/projects/reports' },
|
||||||
|
{ id: 'list', title: 'Projects', icon: <List size={20} />, path: '/projects/list' },
|
||||||
|
{ id: 'tasks', title: 'Tasks', icon: <ClipboardList size={20} />, path: '/projects/tasks' },
|
||||||
|
{ id: 'timesheets', title: 'Timesheets', icon: <Clock size={20} />, path: '/projects/timesheets' },
|
||||||
|
{ id: 'activity-types', title: 'Activity Types', icon: <Tags size={20} />, path: '/projects/activity-types' },
|
||||||
|
{ id: 'templates', title: 'Templates', icon: <FileStack size={20} />, path: '/projects/templates' },
|
||||||
|
{ id: 'profile', title: t('sidebar.userProfile', { defaultValue: 'User Profile' }), icon: <UserCircle size={20} />, path: '/user-profile' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) =>
|
||||||
|
location.pathname === path || location.pathname.startsWith(`${path}/`);
|
||||||
|
|
||||||
|
const isProjectsActive =
|
||||||
|
location.pathname === '/projects' || location.pathname.startsWith('/projects/');
|
||||||
|
|
||||||
|
const mobileDrawerShell = mobileSidebarOpen
|
||||||
|
? `fixed inset-y-0 ${isRTL ? 'right-0' : 'left-0'} z-50 lg:static lg:z-auto`
|
||||||
|
: `fixed inset-y-0 ${isRTL ? 'right-0' : 'left-0'} z-50 -translate-x-full lg:static lg:translate-x-0 lg:z-auto`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mobileSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="pm-app-sidebar-backdrop fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
|
onClick={closeMobileSidebar}
|
||||||
|
aria-label={t('common.close', { defaultValue: 'Close menu' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`pm-app-sidebar h-screen transition-all duration-300 ease-in-out flex flex-col shadow-xl ${edgeBorderClass} ${isCollapsed ? 'w-16' : 'w-64'} ${mobileDrawerShell}`}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${backgroundImageUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/60 dark:bg-black/70 z-0" />
|
||||||
|
<div className="relative z-10 flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200/30 dark:border-gray-700/30">
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 flex items-center justify-center bg-white/20 rounded-lg p-1 backdrop-blur-sm">
|
||||||
|
<img
|
||||||
|
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png${logoVersion}`}
|
||||||
|
alt="Project Management"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-white text-lg font-semibold drop-shadow-lg">
|
||||||
|
{t('sidebar.pmTitle', { defaultValue: 'Project Management' })}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isLgUp) setIsCollapsed(!isCollapsed);
|
||||||
|
else closeMobileSidebar();
|
||||||
|
}}
|
||||||
|
className="text-white hover:bg-white/20 p-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isLgUp ? (isCollapsed ? <Menu size={20} /> : <X size={20} />) : <X size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isCollapsed) {
|
||||||
|
navigate('/projects');
|
||||||
|
afterNav();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsProjectsExpanded((v) => !v);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center px-4 py-3 text-white hover:bg-white/20 transition-all duration-200 ${isProjectsActive ? `bg-white/30 ${activeEdgeBorderClass}` : ''} ${isCollapsed ? 'justify-center' : ''}`}
|
||||||
|
>
|
||||||
|
<FolderOpen size={20} />
|
||||||
|
{!isCollapsed && (
|
||||||
|
<>
|
||||||
|
<span className={`${isRTL ? 'mr-4' : 'ml-4'} font-medium flex-1 text-left`}>
|
||||||
|
{t('sidebar.projects', { defaultValue: 'Project Management' })}
|
||||||
|
</span>
|
||||||
|
<span className="opacity-80">
|
||||||
|
{isProjectsExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && isProjectsExpanded && (
|
||||||
|
<div className={`${isRTL ? 'pr-6' : 'pl-6'} pb-2`}>
|
||||||
|
{mainLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.id}
|
||||||
|
to={link.path}
|
||||||
|
onClick={afterNav}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 text-white/90 hover:text-white hover:bg-white/15 rounded-lg transition-colors ${isActive(link.path) ? 'bg-white/20' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">{link.icon}</span>
|
||||||
|
<span className="text-sm font-medium">{link.title}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
191
pm_app/src/components/VoiceStatusModal.tsx
Normal file
191
pm_app/src/components/VoiceStatusModal.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FaTimes } from 'react-icons/fa';
|
||||||
|
import VoiceStatusWidget from './VoiceStatusWidget';
|
||||||
|
|
||||||
|
interface VoiceStatusModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedRows: Set<string>;
|
||||||
|
onUpdateSuccess: () => void;
|
||||||
|
doctype?: string;
|
||||||
|
fieldname?: string;
|
||||||
|
statusOptions?: StatusOption[];
|
||||||
|
widgetTitle?: string;
|
||||||
|
showLanguageToggle?: boolean;
|
||||||
|
noSelectionLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
/** Arabic display label shown in pills and hints */
|
||||||
|
arLabel?: string;
|
||||||
|
keywords: string[];
|
||||||
|
color: 'completed' | 'in-progress' | 'not-started';
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Work Order statuses (English only) ──────────────────────────────────────
|
||||||
|
const WO_STATUS_OPTIONS: StatusOption[] = [
|
||||||
|
{
|
||||||
|
value: 'Open',
|
||||||
|
label: 'Open',
|
||||||
|
keywords: ['open'],
|
||||||
|
color: 'not-started',
|
||||||
|
icon: '⏳',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Work In Progress',
|
||||||
|
label: 'Work In Progress',
|
||||||
|
keywords: ['work in progress'],
|
||||||
|
color: 'in-progress',
|
||||||
|
icon: '🔄',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Closed',
|
||||||
|
label: 'Closed',
|
||||||
|
keywords: ['closed'],
|
||||||
|
color: 'completed',
|
||||||
|
icon: '✅',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Project statuses (English + Arabic) ─────────────────────────────────────
|
||||||
|
export const PROJECT_STATUS_OPTIONS: StatusOption[] = [
|
||||||
|
{
|
||||||
|
value: 'Open',
|
||||||
|
label: 'Open',
|
||||||
|
arLabel: 'مفتوح',
|
||||||
|
keywords: [
|
||||||
|
// English
|
||||||
|
'open',
|
||||||
|
// Arabic — multiple common pronunciations / spellings
|
||||||
|
'مفتوح', 'مفتوحة', 'افتح', 'open',
|
||||||
|
],
|
||||||
|
color: 'not-started',
|
||||||
|
icon: '📂',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Completed',
|
||||||
|
label: 'Completed',
|
||||||
|
arLabel: 'مكتمل',
|
||||||
|
keywords: [
|
||||||
|
// English
|
||||||
|
'completed', 'complete', 'done', 'finish', 'finished',
|
||||||
|
// Arabic
|
||||||
|
'مكتمل', 'مكتملة', 'اكتمل', 'منجز', 'منتهي', 'منتهية', 'تم',
|
||||||
|
],
|
||||||
|
color: 'completed',
|
||||||
|
icon: '✅',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Cancelled',
|
||||||
|
label: 'Cancelled',
|
||||||
|
arLabel: 'ملغي',
|
||||||
|
keywords: [
|
||||||
|
// English
|
||||||
|
'cancelled', 'canceled', 'cancel',
|
||||||
|
// Arabic
|
||||||
|
'ملغي', 'ملغاة', 'ملغى', 'الغي', 'إلغاء',
|
||||||
|
],
|
||||||
|
color: 'in-progress',
|
||||||
|
icon: '🚫',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
const VoiceStatusModal: React.FC<VoiceStatusModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
selectedRows,
|
||||||
|
onUpdateSuccess,
|
||||||
|
doctype = 'Work_Order',
|
||||||
|
fieldname = 'repair_status',
|
||||||
|
statusOptions = WO_STATUS_OPTIONS,
|
||||||
|
widgetTitle = 'Voice Status Update',
|
||||||
|
showLanguageToggle = false,
|
||||||
|
noSelectionLabel = 'row',
|
||||||
|
}) => {
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const selectedCount = selectedRows.size;
|
||||||
|
|
||||||
|
const handleStatusConfirmed = async (status: string) => {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
|
||||||
|
const rowsToUpdate = Array.from(selectedRows);
|
||||||
|
setIsUpdating(true);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const docName of rowsToUpdate) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/method/frappe.client.set_value', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Frappe-CSRF-Token': (window as any).csrf_token || 'fetch',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ doctype, name: docName, fieldname, value: status }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.exc || data.exception) throw new Error(data.exc || data.exception);
|
||||||
|
successCount++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to update ${docName}:`, err);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(false);
|
||||||
|
|
||||||
|
if (successCount > 0 && failCount === 0) {
|
||||||
|
setTimeout(() => { onUpdateSuccess(); onClose(); }, 2000);
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
onUpdateSuccess();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 flex items-center justify-center z-[80] p-4"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget && !isUpdating) onClose(); }}
|
||||||
|
>
|
||||||
|
<div className="relative animate-scale-in">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (!isUpdating) onClose(); }}
|
||||||
|
className="absolute -top-3 -right-3 z-10 w-8 h-8 rounded-full bg-gray-600 hover:bg-gray-500 text-white flex items-center justify-center shadow-lg transition-colors"
|
||||||
|
disabled={isUpdating}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<FaTimes size={12} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<VoiceStatusWidget
|
||||||
|
onStatusConfirmed={handleStatusConfirmed}
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
selectedNames={Array.from(selectedRows)}
|
||||||
|
isUpdating={isUpdating}
|
||||||
|
statusOptions={statusOptions}
|
||||||
|
widgetTitle={widgetTitle}
|
||||||
|
showLanguageToggle={showLanguageToggle}
|
||||||
|
noSelectionLabel={noSelectionLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes scale-in {
|
||||||
|
from { transform: scale(0.92); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
.animate-scale-in { animation: scale-in 0.2s ease-out; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VoiceStatusModal;
|
||||||
95
pm_app/src/components/VoiceStatusWidget.css
Normal file
95
pm_app/src/components/VoiceStatusWidget.css
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
.vsw-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px 28px 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||||
|
font-family: inherit;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsw-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
||||||
|
.vsw-logo { width: 36px; height: 36px; border-radius: 8px; background: linear-gradient(135deg,#3b82f6,#2563eb); display: flex; align-items: center; justify-content: center; font-size: 16px; }
|
||||||
|
.vsw-brand { font-weight: 700; font-size: 15px; letter-spacing: 0.03em; color: #1e293b; }
|
||||||
|
.vsw-brand span { color: #2563eb; }
|
||||||
|
.vsw-selected-badge { margin-left: auto; background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 100px; }
|
||||||
|
|
||||||
|
.vsw-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; color: #1e293b; }
|
||||||
|
.vsw-subtitle { font-size: 13px; color: #64748b; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.vsw-wo-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
|
||||||
|
.vsw-wo-tag { background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 6px; }
|
||||||
|
.vsw-wo-tag--more { background: #f1f5f9; border-color: #e2e8f0; color: #64748b; }
|
||||||
|
|
||||||
|
.vsw-status-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
|
||||||
|
.vsw-pill { display: flex; align-items: center; gap: 6px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 100px; padding: 6px 14px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s; color: #64748b; user-select: none; }
|
||||||
|
.vsw-pill:hover { border-color: #3b82f6; color: #1e293b; background: #eff6ff; }
|
||||||
|
.vsw-pill--active { color: #1e293b; }
|
||||||
|
.vsw-pill--completed { border-color: #22c55e; background: #f0fdf4; color: #15803d; }
|
||||||
|
.vsw-pill--in-progress { border-color: #f59e0b; background: #fffbeb; color: #b45309; }
|
||||||
|
.vsw-pill--not-started { border-color: #ef4444; background: #fef2f2; color: #dc2626; }
|
||||||
|
|
||||||
|
.vsw-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
|
||||||
|
.vsw-dot--completed { background: #22c55e; }
|
||||||
|
.vsw-dot--in-progress { background: #f59e0b; }
|
||||||
|
.vsw-dot--not-started { background: #ef4444; }
|
||||||
|
|
||||||
|
.vsw-action-area { display: flex; flex-direction: column; align-items: center; gap: 12px; margin-bottom: 20px; min-height: 80px; justify-content: center; }
|
||||||
|
|
||||||
|
.vsw-start-btn { width: 100%; padding: 14px; border-radius: 12px; border: none; background: linear-gradient(135deg,#3b82f6,#2563eb); color: #fff; font-size: 16px; font-weight: 600; font-family: inherit; cursor: pointer; transition: all 0.2s; }
|
||||||
|
.vsw-start-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
|
||||||
|
.vsw-start-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.vsw-status-message { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; padding: 16px 20px; border-radius: 10px; width: 100%; }
|
||||||
|
.vsw-status-message--speaking { flex-direction: column; background: #eff6ff; border: 1px solid #bfdbfe; color: #2563eb; gap: 8px; }
|
||||||
|
.vsw-status-message--detected { background: #f0fdf4; border: 1px solid #bbf7d0; color: #16a34a; font-size: 16px; }
|
||||||
|
|
||||||
|
.vsw-speaking-icon { font-size: 28px; animation: vsw-bounce 0.6s ease-in-out infinite alternate; }
|
||||||
|
.vsw-speaking-text { font-size: 14px; font-weight: 600; color: #2563eb; }
|
||||||
|
.vsw-speaking-dots { display: flex; gap: 4px; }
|
||||||
|
.vsw-speaking-dots span { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #3b82f6; animation: vsw-dot 1.2s ease-in-out infinite; }
|
||||||
|
.vsw-speaking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.vsw-speaking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
.vsw-speak-btn { width: 100%; padding: 14px 16px; border-radius: 12px; border: 1px solid #bfdbfe; background: #eff6ff; color: #1d4ed8; font-family: inherit; cursor: pointer; display: flex; align-items: center; gap: 12px; transition: all 0.2s; animation: vsw-pulse 1.8s ease-in-out infinite; }
|
||||||
|
.vsw-speak-btn:hover { background: #dbeafe; transform: translateY(-1px); }
|
||||||
|
.vsw-speak-btn-icon { width: 40px; height: 40px; border-radius: 50%; background: #2563eb; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||||
|
.vsw-speak-btn-title { display: block; font-size: 15px; font-weight: 600; color: #1d4ed8; }
|
||||||
|
.vsw-speak-btn-hint { display: block; font-size: 11px; color: #3b82f6; margin-top: 2px; }
|
||||||
|
|
||||||
|
.vsw-listening-area { display: flex; flex-direction: column; align-items: center; gap: 8px; width: 100%; }
|
||||||
|
.vsw-mic-active { font-size: 36px; animation: vsw-bounce 1s ease-in-out infinite; }
|
||||||
|
.vsw-waveform { display: flex; align-items: center; gap: 3px; height: 24px; opacity: 0; transition: opacity 0.3s; }
|
||||||
|
.vsw-waveform--active { opacity: 1; }
|
||||||
|
.vsw-bar { width: 3px; border-radius: 2px; background: linear-gradient(to top,#3b82f6,#60a5fa); transition: height 0.08s ease; }
|
||||||
|
.vsw-mic-label { font-size: 13px; color: #64748b; font-weight: 500; }
|
||||||
|
.vsw-mic-label--listening { color: #ef4444; font-weight: 600; animation: vsw-fade 1s ease-in-out infinite; }
|
||||||
|
|
||||||
|
.vsw-transcript { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 12px 16px; font-size: 14px; color: #94a3b8; font-style: italic; margin-bottom: 14px; line-height: 1.6; }
|
||||||
|
.vsw-transcript--filled { color: #1e293b; font-style: normal; border-color: #3b82f6; background: #eff6ff; }
|
||||||
|
|
||||||
|
.vsw-result { border-radius: 10px; padding: 12px 16px; display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 14px; margin-bottom: 14px; }
|
||||||
|
.vsw-result--completed { background: #f0fdf4; border: 1px solid #bbf7d0; color: #15803d; }
|
||||||
|
.vsw-result--in-progress { background: #fffbeb; border: 1px solid #fde68a; color: #b45309; }
|
||||||
|
.vsw-result--not-started { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; }
|
||||||
|
.vsw-result-icon { font-size: 18px; }
|
||||||
|
|
||||||
|
.vsw-divider { border: none; border-top: 1px solid #e2e8f0; margin: 14px 0; }
|
||||||
|
|
||||||
|
.vsw-actions { display: flex; gap: 10px; }
|
||||||
|
.vsw-btn { flex: 1; padding: 10px 18px; border-radius: 8px; border: none; font-family: inherit; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
||||||
|
.vsw-btn--primary { background: #2563eb; color: #fff; }
|
||||||
|
.vsw-btn--primary:hover:not(:disabled) { background: #1d4ed8; transform: translateY(-1px); }
|
||||||
|
.vsw-btn--primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.vsw-btn--confirmed { background: #16a34a !important; }
|
||||||
|
.vsw-btn--ghost { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
|
||||||
|
.vsw-btn--ghost:hover { background: #e2e8f0; color: #1e293b; }
|
||||||
|
|
||||||
|
@keyframes vsw-bounce { 0%,100%{transform:scale(1)} 50%{transform:scale(1.1)} }
|
||||||
|
@keyframes vsw-dot { 0%,80%,100%{transform:translateY(0);opacity:0.4} 40%{transform:translateY(-5px);opacity:1} }
|
||||||
|
@keyframes vsw-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(59,130,246,0.3)} 50%{box-shadow:0 0 0 8px rgba(59,130,246,0)} }
|
||||||
|
@keyframes vsw-fade { 0%,100%{opacity:1} 50%{opacity:0.5} }
|
||||||
|
|
||||||
|
.vsw-status-message--notunderstood { background: #fffbeb; border: 1px solid #fde68a; color: #b45309; font-size: 13px; }
|
||||||
445
pm_app/src/components/VoiceStatusWidget.jsx
Normal file
445
pm_app/src/components/VoiceStatusWidget.jsx
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import "./VoiceStatusWidget.css";
|
||||||
|
// Pre-recorded MP3s — no browser TTS dependency (fixes Android)
|
||||||
|
import arPromptAudio from "../assets/audio/ar_prompt.mp3";
|
||||||
|
import arNoSelectionAudio from "../assets/audio/ar_no_selection.mp3";
|
||||||
|
import enStatusPromptAudio from "../assets/audio/en_status_prompt.mp3";
|
||||||
|
import enNoSelectionAudio from "../assets/audio/en_no_selection_prompt.mp3";
|
||||||
|
|
||||||
|
// ─── Defaults (Work Order) ────────────────────────────────────────────────────
|
||||||
|
const DEFAULT_STATUS_OPTIONS = [
|
||||||
|
{ value: "Open", label: "Open", keywords: ["open"], color: "not-started", icon: "⏳" },
|
||||||
|
{ value: "Work In Progress", label: "Work In Progress", keywords: ["work in progress"], color: "in-progress", icon: "🔄" },
|
||||||
|
{ value: "Closed", label: "Closed", keywords: ["closed"], color: "completed", icon: "✅" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function detectStatus(text, options) {
|
||||||
|
if (!text) return null;
|
||||||
|
const t = text.toLowerCase().trim();
|
||||||
|
for (const opt of options) {
|
||||||
|
if (opt.keywords.some((k) => new RegExp(`(^|[\\s،,])${k}([\\s،,]|$)`, "i").test(t)))
|
||||||
|
return opt.value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NUM_BARS = 18;
|
||||||
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export default function VoiceStatusWidget({
|
||||||
|
onStatusConfirmed,
|
||||||
|
selectedCount = 0,
|
||||||
|
selectedNames = [],
|
||||||
|
isUpdating = false,
|
||||||
|
statusOptions = DEFAULT_STATUS_OPTIONS,
|
||||||
|
widgetTitle = "Voice Status Update",
|
||||||
|
showLanguageToggle = false,
|
||||||
|
noSelectionLabel = "project",
|
||||||
|
}) {
|
||||||
|
const [phase, setPhase] = useState(selectedCount === 0 ? "noselection" : "prompted");
|
||||||
|
const [transcript, setTranscript] = useState("");
|
||||||
|
const [detectedStatus, setDetectedStatus] = useState(null);
|
||||||
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
const [barHeights, setBarHeights] = useState(Array(NUM_BARS).fill(4));
|
||||||
|
const [lang, setLang] = useState("en");
|
||||||
|
|
||||||
|
const audioCtxRef = useRef(null);
|
||||||
|
const sourceRef = useRef(null);
|
||||||
|
const animFrameRef = useRef(null);
|
||||||
|
const recEnRef = useRef(null);
|
||||||
|
const recArRef = useRef(null);
|
||||||
|
const streamRef = useRef(null);
|
||||||
|
const autoStopRef = useRef(null);
|
||||||
|
const calledRef = useRef(false);
|
||||||
|
const detectedRef = useRef(null);
|
||||||
|
|
||||||
|
const enLabels = statusOptions.map((o) => o.label).join(", ");
|
||||||
|
const arLabels = statusOptions.map((o) => o.arLabel || o.label).join("، ");
|
||||||
|
|
||||||
|
const enPrompt = `Please say the status: ${enLabels}.`;
|
||||||
|
const arPrompt = `من فضلك قل الحالة: ${arLabels}.`;
|
||||||
|
|
||||||
|
const noSelectionMsg = `Please select at least one ${noSelectionLabel} from the list in order to continue.`;
|
||||||
|
const arNoSelectionMsg = "من فضلك اختر مشروعاً واحداً على الأقل من القائمة للمتابعة";
|
||||||
|
|
||||||
|
const speakHint = lang === "ar"
|
||||||
|
? `قل: ${arLabels}`
|
||||||
|
: `Say: ${enLabels}`;
|
||||||
|
|
||||||
|
function playPromptForLang(currentLang) {
|
||||||
|
const isAr = currentLang === "ar";
|
||||||
|
const audioFile = isAr ? arPromptAudio : enStatusPromptAudio;
|
||||||
|
const audio = new Audio(audioFile);
|
||||||
|
audio.onended = () => setPhase("ready");
|
||||||
|
audio.onerror = () => setPhase("ready");
|
||||||
|
audio.play().catch(() => setPhase("ready"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
// Play English MP3 first, then Arabic MP3
|
||||||
|
const enAudio = new Audio(enNoSelectionAudio);
|
||||||
|
enAudio.onended = () => {
|
||||||
|
const arAudio = new Audio(arNoSelectionAudio);
|
||||||
|
arAudio.play().catch(() => {});
|
||||||
|
};
|
||||||
|
enAudio.onerror = () => {
|
||||||
|
const arAudio = new Audio(arNoSelectionAudio);
|
||||||
|
arAudio.play().catch(() => {});
|
||||||
|
};
|
||||||
|
enAudio.play().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => playPromptForLang(lang), 1000);
|
||||||
|
return () => { clearTimeout(timer); cleanup(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Re-play when language tab changes
|
||||||
|
const isFirstLangRender = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstLangRender.current) { isFirstLangRender.current = false; return; }
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
if (phase === "listening" || phase === "detected") return;
|
||||||
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
|
setPhase("prompted");
|
||||||
|
const capturedLang = lang;
|
||||||
|
const timer = setTimeout(() => playPromptForLang(capturedLang), 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (autoStopRef.current) clearTimeout(autoStopRef.current);
|
||||||
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||||
|
if (sourceRef.current) { sourceRef.current.disconnect(); sourceRef.current = null; }
|
||||||
|
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null; }
|
||||||
|
if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; }
|
||||||
|
[recEnRef, recArRef].forEach((ref) => {
|
||||||
|
if (ref.current) { try { ref.current.stop(); } catch (_) {} ref.current = null; }
|
||||||
|
});
|
||||||
|
setBarHeights(Array(NUM_BARS).fill(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWaveform() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
audioCtxRef.current = ctx;
|
||||||
|
const analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = 64;
|
||||||
|
const src = ctx.createMediaStreamSource(stream);
|
||||||
|
sourceRef.current = src;
|
||||||
|
src.connect(analyser);
|
||||||
|
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
function frame() {
|
||||||
|
animFrameRef.current = requestAnimationFrame(frame);
|
||||||
|
analyser.getByteFrequencyData(data);
|
||||||
|
setBarHeights(
|
||||||
|
Array.from({ length: NUM_BARS }, (_, i) => {
|
||||||
|
const idx = Math.floor((i * data.length) / NUM_BARS);
|
||||||
|
return 4 + (data[idx] / 255) * 22;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frame();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecognition(language, onResult) {
|
||||||
|
if (!SR) return null;
|
||||||
|
const rec = new SR();
|
||||||
|
rec.lang = language;
|
||||||
|
rec.interimResults = false; // No interim — only final results, prevents Android duplicates
|
||||||
|
rec.continuous = false; // Single utterance — stops after one complete phrase
|
||||||
|
rec.maxAlternatives = 3;
|
||||||
|
rec.onresult = (e) => {
|
||||||
|
// Use resultIndex to get only the new result, not the full cumulative array
|
||||||
|
const r = e.results[e.resultIndex];
|
||||||
|
if (r && r.isFinal) {
|
||||||
|
const text = r[0].transcript.trim();
|
||||||
|
if (text) onResult(text, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rec.onerror = () => {};
|
||||||
|
rec.onend = () => {};
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSpeak() {
|
||||||
|
setPhase("listening");
|
||||||
|
setTranscript("");
|
||||||
|
detectedRef.current = null;
|
||||||
|
await startWaveform();
|
||||||
|
|
||||||
|
if (!SR) { cleanup(); setPhase("ready"); return; }
|
||||||
|
|
||||||
|
const recLang = lang === "ar" ? "ar-SA" : "en-US";
|
||||||
|
let finalTextAccum = "";
|
||||||
|
|
||||||
|
const handleResult = (text, isFinal) => {
|
||||||
|
// Guard: if already detected, ignore all subsequent results (fixes Android duplicate firing)
|
||||||
|
if (calledRef.current || detectedRef.current) return;
|
||||||
|
setTranscript(text);
|
||||||
|
const s = detectStatus(text, statusOptions);
|
||||||
|
if (s) {
|
||||||
|
// Set BOTH flags immediately — before cleanup/setTimeout to block any further results
|
||||||
|
calledRef.current = true;
|
||||||
|
detectedRef.current = s;
|
||||||
|
setDetectedStatus(s);
|
||||||
|
setPhase("detected");
|
||||||
|
cleanup();
|
||||||
|
setTimeout(() => {
|
||||||
|
onStatusConfirmed?.(s);
|
||||||
|
}, 800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isFinal) finalTextAccum += text + " ";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalNoMatch = () => {
|
||||||
|
if (detectedRef.current || calledRef.current) return;
|
||||||
|
if (!finalTextAccum.trim()) return;
|
||||||
|
setPhase("notunderstood");
|
||||||
|
cleanup();
|
||||||
|
const isAr = lang === "ar";
|
||||||
|
if (isAr) {
|
||||||
|
// For Arabic retry — just go back to ready, text shown in UI
|
||||||
|
setTimeout(() => setPhase("ready"), 1500);
|
||||||
|
} else {
|
||||||
|
smartSpeak({
|
||||||
|
text: `Please pick only from one of the values provided: ${enLabels}.`,
|
||||||
|
lang: "en-US",
|
||||||
|
onEnd: () => setPhase("ready"),
|
||||||
|
onError: () => setPhase("ready"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rec = buildRecognition(recLang, handleResult);
|
||||||
|
if (!rec) { cleanup(); setPhase("ready"); return; }
|
||||||
|
|
||||||
|
recEnRef.current = rec;
|
||||||
|
rec.onend = () => {
|
||||||
|
if (!detectedRef.current && !calledRef.current) handleFinalNoMatch();
|
||||||
|
};
|
||||||
|
try { rec.start(); } catch (_) {}
|
||||||
|
|
||||||
|
autoStopRef.current = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
setPhase((prev) => (prev === "listening" ? "ready" : prev));
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleManualSelect(status) {
|
||||||
|
if (calledRef.current) return;
|
||||||
|
setDetectedStatus(status);
|
||||||
|
detectedRef.current = status;
|
||||||
|
setTranscript(`Selected: ${status}`);
|
||||||
|
setPhase("detected");
|
||||||
|
cleanup();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onStatusConfirmed && !calledRef.current) {
|
||||||
|
calledRef.current = true;
|
||||||
|
onStatusConfirmed(status);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
cleanup();
|
||||||
|
calledRef.current = false;
|
||||||
|
detectedRef.current = null;
|
||||||
|
setTranscript("");
|
||||||
|
setDetectedStatus(null);
|
||||||
|
setConfirmed(false);
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
setPhase("noselection");
|
||||||
|
smartSpeak({ text: noSelectionMsg, lang: "en-US", onEnd: () => {}, onError: () => {} });
|
||||||
|
} else {
|
||||||
|
setPhase("prompted");
|
||||||
|
setTimeout(() => playPromptForLang(lang), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = detectedStatus ? statusOptions.find((o) => o.value === detectedStatus) : null;
|
||||||
|
const isArabic = lang === "ar";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vsw-card">
|
||||||
|
<div className="vsw-header">
|
||||||
|
<div className="vsw-logo">🛠️</div>
|
||||||
|
<div className="vsw-brand">SEERA<span>-ASM</span></div>
|
||||||
|
{selectedCount > 0 && <span className="vsw-selected-badge">{selectedCount} selected</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vsw-title">{widgetTitle}</div>
|
||||||
|
<div className="vsw-subtitle">
|
||||||
|
{selectedCount === 0
|
||||||
|
? `No ${noSelectionLabel}s selected`
|
||||||
|
: `Updating ${selectedCount} record${selectedCount > 1 ? "s" : ""}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2-tab language toggle */}
|
||||||
|
{showLanguageToggle && selectedCount > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", gap: 6, marginBottom: 14,
|
||||||
|
background: "#f1f5f9", borderRadius: 10, padding: 4,
|
||||||
|
}}>
|
||||||
|
{[{ key: "en", label: "English" }, { key: "ar", label: "عربي" }].map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setLang(key)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: "6px 0", borderRadius: 7, border: "none",
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: "pointer", transition: "all 0.15s",
|
||||||
|
background: lang === key ? "#fff" : "transparent",
|
||||||
|
color: lang === key ? "#2563eb" : "#64748b",
|
||||||
|
boxShadow: lang === key ? "0 1px 4px rgba(0,0,0,0.1)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No selection */}
|
||||||
|
{phase === "noselection" && (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "flex-start", gap: 12, padding: 16, marginBottom: 16,
|
||||||
|
background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 10,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 20, flexShrink: 0 }}>⚠️</span>
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
{/* English */}
|
||||||
|
<p style={{ fontWeight: 700, fontSize: 14, color: "#92400e", margin: "0 0 2px" }}>
|
||||||
|
No {noSelectionLabel}s selected
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: "#b45309", margin: "0 0 10px", lineHeight: 1.5 }}>
|
||||||
|
Please select at least one {noSelectionLabel} from the list in order to continue.
|
||||||
|
</p>
|
||||||
|
{/* Arabic */}
|
||||||
|
<p style={{ fontWeight: 700, fontSize: 14, color: "#92400e", margin: "0 0 2px", direction: "rtl", textAlign: "right" }}>
|
||||||
|
لم يتم اختيار أي مشروع
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: "#b45309", margin: 0, lineHeight: 1.5, direction: "rtl", textAlign: "right" }}>
|
||||||
|
من فضلك اختر مشروعاً واحداً على الأقل من القائمة للمتابعة
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<>
|
||||||
|
{selectedNames.length > 0 && (
|
||||||
|
<div className="vsw-wo-list">
|
||||||
|
{selectedNames.slice(0, 3).map((n) => <span key={n} className="vsw-wo-tag">{n}</span>)}
|
||||||
|
{selectedNames.length > 3 && <span className="vsw-wo-tag vsw-wo-tag--more">+{selectedNames.length - 3} more</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="vsw-status-row">
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<div
|
||||||
|
key={opt.value}
|
||||||
|
className={`vsw-pill ${detectedStatus === opt.value ? `vsw-pill--active vsw-pill--${opt.color}` : ""}`}
|
||||||
|
onClick={() => handleManualSelect(opt.value)}
|
||||||
|
title="Click to select manually"
|
||||||
|
>
|
||||||
|
<span className={`vsw-dot vsw-dot--${opt.color}`} />
|
||||||
|
<span>{isArabic ? (opt.arLabel || opt.label) : opt.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vsw-action-area">
|
||||||
|
{phase === "prompted" && (
|
||||||
|
<div className="vsw-status-message vsw-status-message--speaking">
|
||||||
|
<div className="vsw-speaking-icon">🔊</div>
|
||||||
|
<div className="vsw-speaking-text">{isArabic ? "يرجى الانتظار…" : "Please wait…"}</div>
|
||||||
|
<div className="vsw-speaking-dots"><span /><span /><span /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "ready" && (
|
||||||
|
<>
|
||||||
|
{/* Arabic text prompt — shown since no Arabic audio */}
|
||||||
|
{isArabic && (
|
||||||
|
<div style={{
|
||||||
|
width: "100%", padding: "12px 16px", marginBottom: 8,
|
||||||
|
background: "#f0f9ff", border: "1px solid #bae6fd",
|
||||||
|
borderRadius: 10, textAlign: "right", direction: "rtl",
|
||||||
|
}}>
|
||||||
|
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: "#0369a1" }}>
|
||||||
|
{arPrompt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button className="vsw-speak-btn" onClick={handleSpeak}>
|
||||||
|
<div className="vsw-speak-btn-icon">🎙️</div>
|
||||||
|
<div className="vsw-speak-btn-text">
|
||||||
|
<span className="vsw-speak-btn-title">{isArabic ? "اضغط للتحدث" : "Tap to Speak"}</span>
|
||||||
|
<span className="vsw-speak-btn-hint">{speakHint}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "listening" && (
|
||||||
|
<div className="vsw-listening-area">
|
||||||
|
<div className="vsw-mic-active">🔴</div>
|
||||||
|
<div className="vsw-waveform vsw-waveform--active">
|
||||||
|
{barHeights.map((h, i) => <div key={i} className="vsw-bar" style={{ height: `${h}px` }} />)}
|
||||||
|
</div>
|
||||||
|
<div className="vsw-mic-label vsw-mic-label--listening">
|
||||||
|
{isArabic ? "جاري الاستماع… تحدث الآن" : "Listening… speak now"}
|
||||||
|
</div>
|
||||||
|
{transcript && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 8, padding: "6px 12px", background: "#f8fafc",
|
||||||
|
border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 13,
|
||||||
|
color: "#1e293b", maxWidth: "100%", direction: isArabic ? "rtl" : "ltr",
|
||||||
|
}}>
|
||||||
|
{transcript}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "detected" && cfg && (
|
||||||
|
<div className="vsw-status-message vsw-status-message--detected">
|
||||||
|
{cfg.icon} {isArabic ? "تم اكتشاف الحالة!" : "Status detected!"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "notunderstood" && (
|
||||||
|
<div className="vsw-status-message vsw-status-message--notunderstood">
|
||||||
|
⚠️ {isArabic ? "من فضلك اختر من القيم المتاحة" : "Please pick only from one of the values provided"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{transcript && phase !== "listening" && (
|
||||||
|
<div className="vsw-transcript vsw-transcript--filled" style={{ direction: isArabic ? "rtl" : "ltr" }}>
|
||||||
|
{transcript}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr className="vsw-divider" />
|
||||||
|
|
||||||
|
<div className="vsw-actions">
|
||||||
|
<button className="vsw-btn vsw-btn--ghost" onClick={handleReset} disabled={isUpdating}>
|
||||||
|
{isArabic ? "إعادة" : "Reset"}
|
||||||
|
</button>
|
||||||
|
{isUpdating && <button className="vsw-btn vsw-btn--primary" disabled>{isArabic ? "جاري التحديث…" : "Updating…"}</button>}
|
||||||
|
{confirmed && <button className="vsw-btn vsw-btn--confirmed vsw-btn--primary" disabled>{isArabic ? "تم التحديث!" : "Updated!"}</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
pm_app/src/components/VoiceTaskUpdateModal.tsx
Normal file
146
pm_app/src/components/VoiceTaskUpdateModal.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FaTimes } from 'react-icons/fa';
|
||||||
|
import VoiceTaskUpdateWidget from './VoiceTaskUpdateWidget';
|
||||||
|
|
||||||
|
interface VoiceTaskUpdateModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
taskName: string;
|
||||||
|
onUpdateSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayFrappe(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoiceTaskUpdateModal: React.FC<VoiceTaskUpdateModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
taskName,
|
||||||
|
onUpdateSuccess,
|
||||||
|
}) => {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleUpdateConfirmed = async (text: string) => {
|
||||||
|
if (!taskName || !text.trim()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setErrorMsg('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Step 1: fetch current document ──────────────────────────────
|
||||||
|
const getRes = await fetch(
|
||||||
|
`/api/resource/Task/${encodeURIComponent(taskName)}`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
const getData = await getRes.json();
|
||||||
|
|
||||||
|
if (getData.exc || getData.exception) {
|
||||||
|
throw new Error(getData.exc || getData.exception || 'Failed to fetch task');
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = getData.data;
|
||||||
|
|
||||||
|
const existingRows: any[] = (doc.custom_task_updates || []).map((r: any) => ({
|
||||||
|
name: r.name,
|
||||||
|
update_: r.update_,
|
||||||
|
date: r.date,
|
||||||
|
task: r.task,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Step 2: append new row ───────────────────────────────────────
|
||||||
|
const newRow = {
|
||||||
|
doctype: 'Updates',
|
||||||
|
update_: text.trim(),
|
||||||
|
date: todayFrappe(),
|
||||||
|
task: taskName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedRows = [...existingRows, newRow];
|
||||||
|
|
||||||
|
// ── Step 3: PUT back ─────────────────────────────────────────────
|
||||||
|
const putRes = await fetch(
|
||||||
|
`/api/resource/Task/${encodeURIComponent(taskName)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Frappe-CSRF-Token': (window as any).csrf_token || 'fetch',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
custom_task_updates: updatedRows,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const putData = await putRes.json();
|
||||||
|
|
||||||
|
if (putData.exc || putData.exception) {
|
||||||
|
throw new Error(putData.exc || putData.exception || 'Failed to save update');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Success: close immediately then refetch ───────────────────────
|
||||||
|
// Close first so the widget never gets a chance to replay the prompt
|
||||||
|
onClose();
|
||||||
|
onUpdateSuccess();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setErrorMsg(`Failed to save: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 flex items-center justify-center z-[80] p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !isSubmitting) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative animate-scale-in">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (!isSubmitting) onClose(); }}
|
||||||
|
className="absolute -top-3 -right-3 z-10 w-8 h-8 rounded-full bg-gray-600 hover:bg-gray-500 text-white flex items-center justify-center shadow-lg transition-colors"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<FaTimes size={12} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="mb-3 px-4 py-2 bg-red-50 border border-red-300 rounded-lg text-red-700 text-sm font-medium flex items-center gap-2">
|
||||||
|
⚠️ {errorMsg}
|
||||||
|
<button onClick={() => setErrorMsg('')} className="ml-auto text-red-400 hover:text-red-600">
|
||||||
|
<FaTimes size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VoiceTaskUpdateWidget
|
||||||
|
onUpdateConfirmed={handleUpdateConfirmed}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes scale-in {
|
||||||
|
from { transform: scale(0.92); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
.animate-scale-in { animation: scale-in 0.2s ease-out; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VoiceTaskUpdateModal;
|
||||||
422
pm_app/src/components/VoiceTaskUpdateWidget.jsx
Normal file
422
pm_app/src/components/VoiceTaskUpdateWidget.jsx
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import arTaskPromptAudio from "../assets/audio/ar_task_prompt.mp3";
|
||||||
|
import enTaskPromptAudio from "../assets/audio/en_task_prompt.mp3";
|
||||||
|
|
||||||
|
const NUM_BARS = 18;
|
||||||
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
|
||||||
|
export default function VoiceTaskUpdateWidget({
|
||||||
|
onUpdateConfirmed,
|
||||||
|
isSubmitting = false,
|
||||||
|
}) {
|
||||||
|
// phase: prompted | ready | listening | preview | saved
|
||||||
|
const [phase, setPhase] = useState("prompted");
|
||||||
|
const [transcript, setTranscript] = useState("");
|
||||||
|
const [editableText, setEditableText] = useState("");
|
||||||
|
const [barHeights, setBarHeights] = useState(Array(NUM_BARS).fill(4));
|
||||||
|
// "en" | "ar"
|
||||||
|
const [lang, setLang] = useState("en");
|
||||||
|
|
||||||
|
const audioCtxRef = useRef(null);
|
||||||
|
const sourceRef = useRef(null);
|
||||||
|
const animFrameRef = useRef(null);
|
||||||
|
const recognitionRef = useRef(null);
|
||||||
|
const streamRef = useRef(null);
|
||||||
|
const autoStopRef = useRef(null);
|
||||||
|
const transcriptRef = useRef(""); // tracks live transcript for onend access
|
||||||
|
|
||||||
|
const isArabic = lang === "ar";
|
||||||
|
|
||||||
|
// ── Mount ─────────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => playPromptForLang(lang), 800);
|
||||||
|
return () => { clearTimeout(timer); cleanup(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Replay when language tab changes ─────────────────────────────────────
|
||||||
|
const isFirstLangRender = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstLangRender.current) { isFirstLangRender.current = false; return; }
|
||||||
|
if (phase === "listening" || phase === "preview" || phase === "saved") return;
|
||||||
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
|
setPhase("prompted");
|
||||||
|
const capturedLang = lang;
|
||||||
|
const timer = setTimeout(() => playPromptForLang(capturedLang), 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
function playPromptForLang(currentLang) {
|
||||||
|
// Use pre-recorded MP3 for both languages — fixes Android speechSynthesis issues
|
||||||
|
const audioFile = currentLang === "ar" ? arTaskPromptAudio : enTaskPromptAudio;
|
||||||
|
const audio = new Audio(audioFile);
|
||||||
|
audio.onended = () => setPhase("ready");
|
||||||
|
audio.onerror = () => setPhase("ready");
|
||||||
|
audio.play().catch(() => setPhase("ready"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (autoStopRef.current) clearTimeout(autoStopRef.current);
|
||||||
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||||
|
if (sourceRef.current) { sourceRef.current.disconnect(); sourceRef.current = null; }
|
||||||
|
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null; }
|
||||||
|
if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; }
|
||||||
|
if (recognitionRef.current) {
|
||||||
|
try { recognitionRef.current.stop(); } catch (_) {}
|
||||||
|
recognitionRef.current = null;
|
||||||
|
}
|
||||||
|
setBarHeights(Array(NUM_BARS).fill(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWaveform() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
audioCtxRef.current = ctx;
|
||||||
|
const analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = 64;
|
||||||
|
const src = ctx.createMediaStreamSource(stream);
|
||||||
|
sourceRef.current = src;
|
||||||
|
src.connect(analyser);
|
||||||
|
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
function frame() {
|
||||||
|
animFrameRef.current = requestAnimationFrame(frame);
|
||||||
|
analyser.getByteFrequencyData(data);
|
||||||
|
setBarHeights(
|
||||||
|
Array.from({ length: NUM_BARS }, (_, i) => {
|
||||||
|
const idx = Math.floor((i * data.length) / NUM_BARS);
|
||||||
|
return 4 + (data[idx] / 255) * 22;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frame();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSpeak() {
|
||||||
|
setPhase("listening");
|
||||||
|
setTranscript("");
|
||||||
|
setEditableText("");
|
||||||
|
transcriptRef.current = "";
|
||||||
|
await startWaveform();
|
||||||
|
|
||||||
|
if (!SR) { cleanup(); setPhase("ready"); return; }
|
||||||
|
|
||||||
|
const rec = new SR();
|
||||||
|
recognitionRef.current = rec;
|
||||||
|
rec.lang = isArabic ? "ar-SA" : "en-US";
|
||||||
|
rec.interimResults = false; // No interim — only get final results, prevents duplicates
|
||||||
|
rec.continuous = false; // Single utterance mode
|
||||||
|
rec.maxAlternatives = 1;
|
||||||
|
|
||||||
|
rec.onresult = (e) => {
|
||||||
|
// Take only the last result's transcript — Android may send multiple onresult events
|
||||||
|
// but each contains the best single transcript so far. The last one is most accurate.
|
||||||
|
const lastResult = e.results[e.resultIndex];
|
||||||
|
if (lastResult && lastResult.isFinal) {
|
||||||
|
const text = lastResult[0].transcript.trim();
|
||||||
|
transcriptRef.current = text;
|
||||||
|
setTranscript(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rec.onend = () => {
|
||||||
|
cleanup();
|
||||||
|
const result = transcriptRef.current.trim();
|
||||||
|
if (result.length > 0) {
|
||||||
|
setEditableText(result);
|
||||||
|
setPhase("preview");
|
||||||
|
} else {
|
||||||
|
setPhase("ready");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rec.onerror = () => { cleanup(); setPhase("ready"); };
|
||||||
|
rec.start();
|
||||||
|
|
||||||
|
// Auto-stop after 15s as safety fallback
|
||||||
|
autoStopRef.current = setTimeout(() => {
|
||||||
|
if (recognitionRef.current) recognitionRef.current.stop();
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStopListening() {
|
||||||
|
if (recognitionRef.current) recognitionRef.current.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (!editableText.trim() || isSubmitting) return;
|
||||||
|
onUpdateConfirmed(editableText.trim());
|
||||||
|
setPhase("saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
cleanup();
|
||||||
|
setTranscript("");
|
||||||
|
setEditableText("");
|
||||||
|
transcriptRef.current = "";
|
||||||
|
setPhase("ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
cleanup();
|
||||||
|
setTranscript("");
|
||||||
|
setEditableText("");
|
||||||
|
transcriptRef.current = "";
|
||||||
|
setPhase("prompted");
|
||||||
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
|
setTimeout(() => playPromptForLang(lang), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={styles.header}>
|
||||||
|
<div style={styles.logo}>📝</div>
|
||||||
|
<div style={styles.brand}>SEERA<span style={styles.brandAccent}>-ASM</span></div>
|
||||||
|
<span style={styles.badge}>Task Update</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.title}>Voice Task Update</div>
|
||||||
|
<div style={styles.subtitle}>
|
||||||
|
Speak your progress note — it will be added to Task Updates
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 2-tab language toggle ── */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", gap: 6, marginBottom: 16,
|
||||||
|
background: "#f1f5f9", borderRadius: 10, padding: 4,
|
||||||
|
}}>
|
||||||
|
{[{ key: "en", label: "English" }, { key: "ar", label: "عربي" }].map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setLang(key)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: "6px 0", borderRadius: 7, border: "none",
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: "pointer", transition: "all 0.15s",
|
||||||
|
background: lang === key ? "#fff" : "transparent",
|
||||||
|
color: lang === key ? "#7c3aed" : "#64748b",
|
||||||
|
boxShadow: lang === key ? "0 1px 4px rgba(0,0,0,0.1)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action area */}
|
||||||
|
<div style={styles.actionArea}>
|
||||||
|
|
||||||
|
{phase === "prompted" && (
|
||||||
|
<div style={{ ...styles.statusMsg, ...styles.speakingMsg }}>
|
||||||
|
<div style={styles.speakingIcon}>🔊</div>
|
||||||
|
<div style={styles.speakingText}>
|
||||||
|
{isArabic ? "يرجى الانتظار…" : "Please wait…"}
|
||||||
|
</div>
|
||||||
|
<div style={styles.dots}>
|
||||||
|
<span style={styles.dot} />
|
||||||
|
<span style={{ ...styles.dot, animationDelay: "0.2s" }} />
|
||||||
|
<span style={{ ...styles.dot, animationDelay: "0.4s" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "ready" && (
|
||||||
|
<button style={styles.speakBtn} onClick={handleSpeak}>
|
||||||
|
<div style={styles.speakBtnIcon}>🎙️</div>
|
||||||
|
<div>
|
||||||
|
<span style={styles.speakBtnTitle}>
|
||||||
|
{isArabic ? "اضغط للتحدث" : "Tap to Speak"}
|
||||||
|
</span>
|
||||||
|
<span style={styles.speakBtnHint}>
|
||||||
|
{isArabic ? "تحدث عن تحديث مهمتك بوضوح" : "Speak your task update clearly"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "listening" && (
|
||||||
|
<div style={styles.listeningArea}>
|
||||||
|
<div style={styles.micActive}>🔴</div>
|
||||||
|
<div style={styles.waveform}>
|
||||||
|
{barHeights.map((h, i) => (
|
||||||
|
<div key={i} style={{ ...styles.bar, height: `${h}px` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={styles.micLabel}>
|
||||||
|
{isArabic ? "جاري الاستماع… تحدث الآن" : "Listening… speak your update"}
|
||||||
|
</div>
|
||||||
|
{transcript && (
|
||||||
|
<div style={styles.liveTranscript}>
|
||||||
|
<em style={{ color: "#64748b", fontSize: 12 }}>
|
||||||
|
{isArabic ? "يسمع:" : "Hearing:"}
|
||||||
|
</em>
|
||||||
|
<p style={{
|
||||||
|
margin: "4px 0 0", fontSize: 13, color: "#1e293b",
|
||||||
|
direction: isArabic ? "rtl" : "ltr",
|
||||||
|
}}>
|
||||||
|
{transcript}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button style={styles.stopBtn} onClick={handleStopListening}>
|
||||||
|
{isArabic ? "⏹ انتهيت" : "⏹ Done Speaking"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "preview" && (
|
||||||
|
<div style={styles.previewArea}>
|
||||||
|
<div style={styles.previewLabel}>
|
||||||
|
{isArabic ? "✅ تم! راجع وعدّل إذا لزم:" : "✅ Got it! Review and edit if needed:"}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
style={{ ...styles.textarea, direction: isArabic ? "rtl" : "ltr" }}
|
||||||
|
value={editableText}
|
||||||
|
onChange={e => setEditableText(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder={isArabic ? "نص التحديث…" : "Your update text…"}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p style={styles.previewHint}>
|
||||||
|
{isArabic ? "📅 سيتم إضافة تاريخ اليوم تلقائياً" : "📅 Today's date will be added automatically"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "saved" && (
|
||||||
|
<div style={{ ...styles.statusMsg, ...styles.savedMsg }}>
|
||||||
|
{isArabic ? "✅ تمت إضافة التحديث!" : "✅ Update added to task!"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom actions */}
|
||||||
|
<div style={styles.actions}>
|
||||||
|
{phase !== "saved" && (
|
||||||
|
<button style={styles.ghostBtn} onClick={handleReset} disabled={isSubmitting}>
|
||||||
|
{isArabic ? "إعادة" : "Reset"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{phase === "listening" && (
|
||||||
|
<button style={{ ...styles.primaryBtn, background: "#dc2626" }} onClick={handleStopListening}>
|
||||||
|
{isArabic ? "إيقاف" : "Stop"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{phase === "preview" && (
|
||||||
|
<>
|
||||||
|
<button style={styles.ghostBtn} onClick={handleRetry}>
|
||||||
|
{isArabic ? "🔄 إعادة التسجيل" : "🔄 Re-record"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ ...styles.primaryBtn, ...(isSubmitting ? styles.disabledBtn : {}) }}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isSubmitting || !editableText.trim()}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? (isArabic ? "جاري الحفظ…" : "Saving…")
|
||||||
|
: (isArabic ? "✅ إضافة التحديث" : "✅ Add Update")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === "saved" && (
|
||||||
|
<button style={styles.ghostBtn} onClick={handleReset}>
|
||||||
|
{isArabic ? "إضافة تحديث آخر" : "Add Another"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
card: {
|
||||||
|
background: "#ffffff", border: "1px solid #e2e8f0", borderRadius: 16,
|
||||||
|
padding: "28px 28px 24px", width: "100%", maxWidth: 480,
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,0.12)", fontFamily: "inherit", color: "#1e293b",
|
||||||
|
},
|
||||||
|
header: { display: "flex", alignItems: "center", gap: 10, marginBottom: 20 },
|
||||||
|
logo: {
|
||||||
|
width: 36, height: 36, borderRadius: 8,
|
||||||
|
background: "linear-gradient(135deg,#8b5cf6,#7c3aed)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16,
|
||||||
|
},
|
||||||
|
brand: { fontWeight: 700, fontSize: 15, letterSpacing: "0.03em", color: "#1e293b" },
|
||||||
|
brandAccent: { color: "#7c3aed" },
|
||||||
|
badge: {
|
||||||
|
marginLeft: "auto", background: "#f5f3ff", border: "1px solid #ddd6fe",
|
||||||
|
color: "#6d28d9", fontSize: 11, fontWeight: 600, padding: "3px 10px", borderRadius: 100,
|
||||||
|
},
|
||||||
|
title: { fontSize: 22, fontWeight: 700, marginBottom: 4 },
|
||||||
|
subtitle: { fontSize: 13, color: "#64748b", marginBottom: 12 },
|
||||||
|
actionArea: {
|
||||||
|
display: "flex", flexDirection: "column", alignItems: "center",
|
||||||
|
gap: 12, marginBottom: 20, minHeight: 100, justifyContent: "center",
|
||||||
|
},
|
||||||
|
statusMsg: {
|
||||||
|
display: "flex", alignItems: "center", gap: 10, fontSize: 14,
|
||||||
|
fontWeight: 600, padding: "16px 20px", borderRadius: 10, width: "100%",
|
||||||
|
},
|
||||||
|
speakingMsg: {
|
||||||
|
flexDirection: "column", background: "#f5f3ff",
|
||||||
|
border: "1px solid #ddd6fe", color: "#7c3aed", gap: 8,
|
||||||
|
},
|
||||||
|
savedMsg: {
|
||||||
|
background: "#f0fdf4", border: "1px solid #bbf7d0",
|
||||||
|
color: "#16a34a", fontSize: 16, justifyContent: "center",
|
||||||
|
},
|
||||||
|
speakingIcon: { fontSize: 28 },
|
||||||
|
speakingText: { fontSize: 14, fontWeight: 600, color: "#7c3aed" },
|
||||||
|
dots: { display: "flex", gap: 4 },
|
||||||
|
dot: {
|
||||||
|
display: "inline-block", width: 6, height: 6, borderRadius: "50%",
|
||||||
|
background: "#8b5cf6", animation: "vsw-dot 1.2s ease-in-out infinite",
|
||||||
|
},
|
||||||
|
speakBtn: {
|
||||||
|
width: "100%", padding: "14px 16px", borderRadius: 12,
|
||||||
|
border: "1px solid #ddd6fe", background: "#f5f3ff", color: "#5b21b6",
|
||||||
|
fontFamily: "inherit", cursor: "pointer", display: "flex",
|
||||||
|
alignItems: "center", gap: 12, transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
speakBtnIcon: {
|
||||||
|
width: 40, height: 40, borderRadius: "50%", background: "#7c3aed",
|
||||||
|
color: "#fff", display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 18, flexShrink: 0,
|
||||||
|
},
|
||||||
|
speakBtnTitle: { display: "block", fontSize: 15, fontWeight: 600, color: "#5b21b6" },
|
||||||
|
speakBtnHint: { display: "block", fontSize: 11, color: "#8b5cf6", marginTop: 2 },
|
||||||
|
listeningArea: { display: "flex", flexDirection: "column", alignItems: "center", gap: 8, width: "100%" },
|
||||||
|
micActive: { fontSize: 36 },
|
||||||
|
waveform: { display: "flex", alignItems: "center", gap: 3, height: 30 },
|
||||||
|
bar: { width: 3, borderRadius: 2, background: "linear-gradient(to top,#8b5cf6,#c4b5fd)", transition: "height 0.08s ease" },
|
||||||
|
micLabel: { fontSize: 13, color: "#ef4444", fontWeight: 600 },
|
||||||
|
liveTranscript: {
|
||||||
|
width: "100%", background: "#f8fafc", border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: 8, padding: "8px 12px", marginTop: 4,
|
||||||
|
},
|
||||||
|
stopBtn: {
|
||||||
|
marginTop: 8, padding: "8px 20px", borderRadius: 8,
|
||||||
|
border: "1px solid #fecaca", background: "#fef2f2",
|
||||||
|
color: "#dc2626", fontFamily: "inherit", fontSize: 13, fontWeight: 600, cursor: "pointer",
|
||||||
|
},
|
||||||
|
previewArea: { width: "100%" },
|
||||||
|
previewLabel: { fontSize: 13, fontWeight: 600, color: "#16a34a", marginBottom: 8 },
|
||||||
|
textarea: {
|
||||||
|
width: "100%", padding: "10px 12px", border: "1px solid #c4b5fd",
|
||||||
|
borderRadius: 8, fontSize: 14, fontFamily: "inherit", color: "#1e293b",
|
||||||
|
background: "#fafafa", resize: "vertical", outline: "none", boxSizing: "border-box",
|
||||||
|
},
|
||||||
|
previewHint: { fontSize: 11, color: "#64748b", marginTop: 6 },
|
||||||
|
actions: { display: "flex", gap: 10 },
|
||||||
|
ghostBtn: {
|
||||||
|
flex: 1, padding: "10px 18px", borderRadius: 8,
|
||||||
|
border: "1px solid #e2e8f0", background: "#f1f5f9",
|
||||||
|
color: "#475569", fontFamily: "inherit", fontSize: 14, fontWeight: 600, cursor: "pointer",
|
||||||
|
},
|
||||||
|
primaryBtn: {
|
||||||
|
flex: 1, padding: "10px 18px", borderRadius: 8, border: "none",
|
||||||
|
background: "#7c3aed", color: "#fff", fontFamily: "inherit",
|
||||||
|
fontSize: 14, fontWeight: 600, cursor: "pointer",
|
||||||
|
},
|
||||||
|
disabledBtn: { opacity: 0.45, cursor: "not-allowed" },
|
||||||
|
};
|
||||||
248
pm_app/src/components/WorkflowActions.tsx
Normal file
248
pm_app/src/components/WorkflowActions.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useWorkflow } from '../hooks/useWorkflow.ts';
|
||||||
|
import type { WorkflowTransition } from '../services/workflowService';
|
||||||
|
import { FaSpinner, FaExclamationTriangle, FaInfoCircle } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface WorkflowActionsProps {
|
||||||
|
doctype: string;
|
||||||
|
docname: string | null;
|
||||||
|
workflowState?: string;
|
||||||
|
/** Merged doc + form for workflow condition evaluation */
|
||||||
|
docData?: Record<string, any>;
|
||||||
|
onActionComplete?: (action: string, success: boolean) => void;
|
||||||
|
onStateChange?: () => void;
|
||||||
|
/** Fired when canEdit / workflow is resolved (existing docs only) */
|
||||||
|
onWorkflowMeta?: (meta: { canEdit: boolean }) => void;
|
||||||
|
/** Word used in confirm dialogs, e.g. "Material Request" */
|
||||||
|
documentLabel?: string;
|
||||||
|
showStateInfo?: boolean;
|
||||||
|
/** Label above the current state (default: "Workflow State") */
|
||||||
|
stateHeading?: string;
|
||||||
|
/** Purple note when user has full workflow access (e.g. System Manager) */
|
||||||
|
showFullAccessNote?: boolean;
|
||||||
|
/** Omit the whole block when there is no active workflow for this doctype */
|
||||||
|
hideWhenNoWorkflow?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkflowActions: React.FC<WorkflowActionsProps> = ({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
workflowState,
|
||||||
|
docData,
|
||||||
|
onActionComplete,
|
||||||
|
onStateChange,
|
||||||
|
onWorkflowMeta,
|
||||||
|
documentLabel = 'document',
|
||||||
|
showStateInfo = true,
|
||||||
|
stateHeading = 'Workflow State',
|
||||||
|
showFullAccessNote = false,
|
||||||
|
hideWhenNoWorkflow = false,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
transitions,
|
||||||
|
loading,
|
||||||
|
actionLoading,
|
||||||
|
error,
|
||||||
|
applyAction,
|
||||||
|
canEdit,
|
||||||
|
isSystemManager,
|
||||||
|
workflowInfo,
|
||||||
|
getStateStyle,
|
||||||
|
getButtonStyle,
|
||||||
|
getIcon,
|
||||||
|
} = useWorkflow({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
workflowState,
|
||||||
|
enabled: !!docname,
|
||||||
|
docData,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!docname) return;
|
||||||
|
onWorkflowMeta?.({ canEdit });
|
||||||
|
}, [docname, canEdit, onWorkflowMeta]);
|
||||||
|
|
||||||
|
const [confirmAction, setConfirmAction] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Actions that require confirmation
|
||||||
|
const actionsRequiringConfirmation = ['Reject', 'Cancel', 'Close'];
|
||||||
|
|
||||||
|
const handleActionClick = async (action: string, nextState?: string) => {
|
||||||
|
// Check if action requires confirmation
|
||||||
|
if (actionsRequiringConfirmation.includes(action) && confirmAction !== action) {
|
||||||
|
setConfirmAction(action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfirmAction(null);
|
||||||
|
|
||||||
|
const success = await applyAction(action, nextState);
|
||||||
|
|
||||||
|
if (onActionComplete) {
|
||||||
|
onActionComplete(action, success);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success && onStateChange) {
|
||||||
|
onStateChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelConfirm = () => {
|
||||||
|
setConfirmAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!docname) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideWhenNoWorkflow && !loading && !workflowInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateStyle = workflowState ? getStateStyle(workflowState) : getStateStyle('Draft');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
{/* Current State Display */}
|
||||||
|
{showStateInfo && workflowState && (
|
||||||
|
<div className={`p-4 rounded-lg border ${stateStyle.bg} ${stateStyle.border}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{stateHeading}</p>
|
||||||
|
<p className={`text-lg font-semibold ${stateStyle.text}`}>
|
||||||
|
{workflowState}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`w-3 h-3 rounded-full ${stateStyle.bg.replace('100', '500').replace('900/30', '500')}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
|
||||||
|
<FaSpinner className="animate-spin" />
|
||||||
|
<span className="text-sm">Loading workflow actions...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FaExclamationTriangle className="text-red-500 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
{confirmAction && (
|
||||||
|
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2 mb-3">
|
||||||
|
<FaExclamationTriangle className="text-yellow-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
Confirm Action
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
|
||||||
|
Are you sure you want to <strong>{confirmAction}</strong> this {documentLabel}?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const t = transitions.find(tr => tr.action === confirmAction);
|
||||||
|
handleActionClick(confirmAction, t?.next_state);
|
||||||
|
}}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded-md disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FaSpinner className="animate-spin" size={12} />
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
`Yes, ${confirmAction}`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelConfirm}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="px-3 py-1.5 bg-gray-300 hover:bg-gray-400 text-gray-700 text-sm rounded-md disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available Actions */}
|
||||||
|
{!loading && transitions.length > 0 && !confirmAction && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{showFullAccessNote && isSystemManager && (
|
||||||
|
<div className="p-2 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg mb-2">
|
||||||
|
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||||
|
{t('workOrders.detail.systemManagerNote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||||
|
<FaInfoCircle size={12} />
|
||||||
|
Available Actions ({transitions.length})
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{transitions.map((transition: WorkflowTransition, index: number) => (
|
||||||
|
<button
|
||||||
|
key={`${transition.action}-${index}`}
|
||||||
|
onClick={() => handleActionClick(transition.action, transition.next_state)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 flex items-center gap-2 ${getButtonStyle(transition.action)}`}
|
||||||
|
title={`Move to: ${transition.next_state}`}
|
||||||
|
>
|
||||||
|
{actionLoading ? (
|
||||||
|
<FaSpinner className="animate-spin" size={14} />
|
||||||
|
) : (
|
||||||
|
<span>{getIcon(transition.action)}</span>
|
||||||
|
)}
|
||||||
|
{transition.action}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show next states info */}
|
||||||
|
<div className="mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{t('issues.actionResults')}
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{transitions.map((tr: WorkflowTransition, i: number) => (
|
||||||
|
<span key={i} className="inline-block mr-3">
|
||||||
|
{tr.action} → <span className="font-medium">{tr.next_state}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Actions Available */}
|
||||||
|
{!loading && transitions.length === 0 && docname && (
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||||
|
No workflow actions available for your role
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkflowActions;
|
||||||
31
pm_app/src/config/api.ts
Normal file
31
pm_app/src/config/api.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
interface ApiConfig {
|
||||||
|
BASE_URL: string;
|
||||||
|
ENDPOINTS: Record<string, string>;
|
||||||
|
DEFAULT_HEADERS: Record<string, string>;
|
||||||
|
TIMEOUT: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_CONFIG: ApiConfig = {
|
||||||
|
BASE_URL: import.meta.env.VITE_FRAPPE_BASE_URL || 'http://172.25.161.96:8000',
|
||||||
|
ENDPOINTS: {
|
||||||
|
LOGIN: '/api/method/login',
|
||||||
|
RESET_PASSWORD: '/api/method/frappe.core.doctype.user.user.reset_password',
|
||||||
|
LOGOUT: '/api/method/logout',
|
||||||
|
CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token',
|
||||||
|
TWO_FACTOR_STATUS: '/api/method/project_management.api.two_factor.get_two_factor_status',
|
||||||
|
UPLOAD_FILE: '/api/method/upload_file',
|
||||||
|
GET_USER_PERMISSIONS: '/api/method/asset_lite.api.userperm_api.get_user_permissions',
|
||||||
|
GET_PERMISSION_FILTERS: '/api/method/asset_lite.api.userperm_api.get_permission_filters',
|
||||||
|
GET_ALLOWED_VALUES: '/api/method/asset_lite.api.userperm_api.get_allowed_values',
|
||||||
|
CHECK_DOCUMENT_ACCESS: '/api/method/asset_lite.api.userperm_api.check_document_access',
|
||||||
|
GET_CONFIGURED_DOCTYPES: '/api/method/asset_lite.api.userperm_api.get_configured_doctypes',
|
||||||
|
GET_USER_DEFAULTS: '/api/method/asset_lite.api.userperm_api.get_user_defaults',
|
||||||
|
},
|
||||||
|
DEFAULT_HEADERS: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
TIMEOUT: parseInt(import.meta.env.VITE_API_TIMEOUT || '60000'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default API_CONFIG;
|
||||||
25
pm_app/src/constants/orgDefaults.ts
Normal file
25
pm_app/src/constants/orgDefaults.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/** Default company and currency when not specified in URL or on the loaded document. */
|
||||||
|
export const DEFAULT_COMPANY = 'Seera Arabia';
|
||||||
|
export const DEFAULT_CURRENCY = 'SAR';
|
||||||
|
|
||||||
|
/** Default sales tax template for new Sales Order / Delivery Note / Sales Invoice. */
|
||||||
|
export const DEFAULT_SALES_TAXES_TEMPLATE = 'KSA Tax Charges - SA';
|
||||||
|
|
||||||
|
/** Frappe child tables use `rate`; editors often use `tax_rate`. */
|
||||||
|
export function taxRatePercent(tx: { tax_rate?: number; rate?: number }): number {
|
||||||
|
const v = tx.tax_rate ?? tx.rate;
|
||||||
|
return typeof v === 'number' && !Number.isNaN(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show SAR when document still has legacy INR (org default is SAR). */
|
||||||
|
export function displayTxnCurrency(code?: string | null): string {
|
||||||
|
const c = (code || '').trim();
|
||||||
|
if (!c || c === 'INR') return DEFAULT_CURRENCY;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display amounts with SAR prefix (org standard; avoids ₹ / INR in UI). */
|
||||||
|
export function formatOrgCurrencyAmount(value?: number | null): string {
|
||||||
|
const n = Number(value ?? 0);
|
||||||
|
return `SAR ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
69
pm_app/src/contexts/LanguageContext.tsx
Normal file
69
pm_app/src/contexts/LanguageContext.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { loadFrappeTranslations } from '../i18n';
|
||||||
|
|
||||||
|
type Language = 'en' | 'ar';
|
||||||
|
|
||||||
|
interface LanguageContextType {
|
||||||
|
language: Language;
|
||||||
|
changeLanguage: (lang: Language) => Promise<void>;
|
||||||
|
isRTL: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const [language, setLanguage] = useState<Language>(() => {
|
||||||
|
const saved = localStorage.getItem('i18nextLng') as Language;
|
||||||
|
return saved === 'ar' ? 'ar' : 'en';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isRTL = language === 'ar';
|
||||||
|
|
||||||
|
// Apply language and RTL on mount and when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const html = document.documentElement;
|
||||||
|
|
||||||
|
// Update i18n language
|
||||||
|
i18n.changeLanguage(language);
|
||||||
|
|
||||||
|
// Update HTML lang attribute
|
||||||
|
html.setAttribute('lang', language);
|
||||||
|
|
||||||
|
// Update HTML dir attribute for RTL
|
||||||
|
if (isRTL) {
|
||||||
|
html.setAttribute('dir', 'rtl');
|
||||||
|
root.classList.add('rtl');
|
||||||
|
root.classList.remove('ltr');
|
||||||
|
} else {
|
||||||
|
html.setAttribute('dir', 'ltr');
|
||||||
|
root.classList.add('ltr');
|
||||||
|
root.classList.remove('rtl');
|
||||||
|
}
|
||||||
|
}, [language, i18n, isRTL]);
|
||||||
|
|
||||||
|
const changeLanguage = async (lang: Language) => {
|
||||||
|
setLanguage(lang);
|
||||||
|
localStorage.setItem('i18nextLng', lang);
|
||||||
|
// Reload translations from Frappe when language changes
|
||||||
|
await loadFrappeTranslations();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LanguageContext.Provider value={{ language, changeLanguage, isRTL }}>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLanguage = () => {
|
||||||
|
const context = useContext(LanguageContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useLanguage must be used within LanguageProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
37
pm_app/src/contexts/SidebarLayoutContext.tsx
Normal file
37
pm_app/src/contexts/SidebarLayoutContext.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
interface SidebarLayoutContextType {
|
||||||
|
mobileSidebarOpen: boolean;
|
||||||
|
openMobileSidebar: () => void;
|
||||||
|
closeMobileSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarLayoutContext = createContext<SidebarLayoutContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const SidebarLayoutProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const openMobileSidebar = useCallback(() => {
|
||||||
|
setMobileSidebarOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeMobileSidebar = useCallback(() => {
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarLayoutContext.Provider
|
||||||
|
value={{ mobileSidebarOpen, openMobileSidebar, closeMobileSidebar }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarLayoutContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSidebarLayout = () => {
|
||||||
|
const context = useContext(SidebarLayoutContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSidebarLayout must be used within SidebarLayoutProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
48
pm_app/src/contexts/ThemeContext.tsx
Normal file
48
pm_app/src/contexts/ThemeContext.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const saved = localStorage.getItem('theme');
|
||||||
|
return (saved as Theme) || 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply theme on mount and when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
|
||||||
|
if (theme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(prev => prev === 'light' ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
111
pm_app/src/hooks/useAuditLogs.ts
Normal file
111
pm_app/src/hooks/useAuditLogs.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
// ============== INTERFACES ==============
|
||||||
|
|
||||||
|
export interface VersionChange {
|
||||||
|
field: string;
|
||||||
|
oldValue: any;
|
||||||
|
newValue: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
name: string;
|
||||||
|
owner: string;
|
||||||
|
creation: string;
|
||||||
|
changes: VersionChange[];
|
||||||
|
added: any[];
|
||||||
|
removed: any[];
|
||||||
|
rowChanged: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAuditLogsOptions {
|
||||||
|
doctype: string;
|
||||||
|
docname: string | null;
|
||||||
|
limit?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAuditLogsReturn {
|
||||||
|
auditLogs: AuditLogEntry[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== HOOK ==============
|
||||||
|
|
||||||
|
export const useAuditLogs = ({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
limit = 50,
|
||||||
|
enabled = true,
|
||||||
|
}: UseAuditLogsOptions): UseAuditLogsReturn => {
|
||||||
|
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAuditLogs = useCallback(async () => {
|
||||||
|
if (!enabled || !doctype || !docname) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiService.apiCall<any>(
|
||||||
|
`/api/resource/Version?filters=[["ref_doctype","=","${encodeURIComponent(doctype)}"],["docname","=","${encodeURIComponent(docname)}"]]&fields=["name","owner","creation","data"]&order_by=creation desc&limit=${limit}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response?.data && response.data.length > 0) {
|
||||||
|
const parsedLogs: AuditLogEntry[] = response.data.map((version: any) => {
|
||||||
|
let parsedData = { added: [], changed: [], removed: [], row_changed: [] };
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(version.data || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing version data:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: VersionChange[] = (parsedData.changed || []).map((change: any[]) => ({
|
||||||
|
field: change[0] || '',
|
||||||
|
oldValue: change[1],
|
||||||
|
newValue: change[2],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: version.name,
|
||||||
|
owner: version.owner,
|
||||||
|
creation: version.creation,
|
||||||
|
changes,
|
||||||
|
added: parsedData.added || [],
|
||||||
|
removed: parsedData.removed || [],
|
||||||
|
rowChanged: parsedData.row_changed || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setAuditLogs(parsedLogs);
|
||||||
|
} else {
|
||||||
|
setAuditLogs([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching audit logs for ${doctype}/${docname}:`, err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load activity log');
|
||||||
|
setAuditLogs([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [doctype, docname, limit, enabled]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAuditLogs();
|
||||||
|
}, [fetchAuditLogs]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
auditLogs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchAuditLogs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAuditLogs;
|
||||||
77
pm_app/src/hooks/useDocTypeMeta.ts
Normal file
77
pm_app/src/hooks/useDocTypeMeta.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
export interface DocTypeField {
|
||||||
|
fieldname: string;
|
||||||
|
fieldtype: string;
|
||||||
|
label: string;
|
||||||
|
allow_on_submit: number; // 0 or 1
|
||||||
|
reqd: number; // 0 or 1 for required
|
||||||
|
read_only: number; // 0 or 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDocTypeMeta = (doctype: string) => {
|
||||||
|
const [fields, setFields] = useState<DocTypeField[]>([]);
|
||||||
|
const [allowOnSubmitFields, setAllowOnSubmitFields] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDocTypeMeta = async () => {
|
||||||
|
if (!doctype) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiService.apiCall<any>(
|
||||||
|
`/api/resource/DocType/${doctype}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle different response structures from Frappe API
|
||||||
|
// Response can be: { data: {...} } or directly {...}
|
||||||
|
const docTypeData = response.data || response;
|
||||||
|
const fieldsList: DocTypeField[] = docTypeData.fields || [];
|
||||||
|
|
||||||
|
// Extract fields that allow editing on submit
|
||||||
|
const allowOnSubmitSet = new Set<string>();
|
||||||
|
fieldsList.forEach((field: DocTypeField) => {
|
||||||
|
// Check both number (1) and boolean (true) formats
|
||||||
|
// if (field.allow_on_submit === 1 || field.allow_on_submit === true) {
|
||||||
|
if (field.allow_on_submit === 1){
|
||||||
|
allowOnSubmitSet.add(field.fieldname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug logging (development only)
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(`[DocTypeMeta] Loaded ${fieldsList.length} fields for ${doctype}`);
|
||||||
|
console.log(`[DocTypeMeta] Fields with allow_on_submit:`, Array.from(allowOnSubmitSet));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFields(fieldsList);
|
||||||
|
setAllowOnSubmitFields(allowOnSubmitSet);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[DocTypeMeta] Error fetching DocType meta for ${doctype}:`, err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
// Don't block the UI if metadata fetch fails - allow all fields to be editable
|
||||||
|
// This is a graceful degradation
|
||||||
|
setFields([]);
|
||||||
|
setAllowOnSubmitFields(new Set());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDocTypeMeta();
|
||||||
|
}, [doctype]);
|
||||||
|
|
||||||
|
const isAllowedOnSubmit = (fieldname: string): boolean => {
|
||||||
|
return allowOnSubmitFields.has(fieldname);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { fields, allowOnSubmitFields, isAllowedOnSubmit, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
153
pm_app/src/hooks/useDoctypeFields.ts
Normal file
153
pm_app/src/hooks/useDoctypeFields.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* useDoctypeFields.ts — v6 (server-side field resolution)
|
||||||
|
*
|
||||||
|
* ROOT CAUSE OF ALL PREVIOUS VERSIONS:
|
||||||
|
* ─────────────────────────────────────
|
||||||
|
* Frappe's meta APIs (/api/resource/DocType and /api/resource/Custom Field)
|
||||||
|
* are ROLE-FILTERED by design. Frappe strips fields the current user's role
|
||||||
|
* cannot read BEFORE returning the response. No client-side cache strategy
|
||||||
|
* can fix this because the *source data* is already wrong.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* Contractor Engineer → /api/resource/DocType/Work_Order → 6 fields
|
||||||
|
* Administrator → /api/resource/DocType/Work_Order → 45 fields
|
||||||
|
*
|
||||||
|
* THE REAL FIX:
|
||||||
|
* ─────────────
|
||||||
|
* Call a whitelisted Python function (`asset_lite.api.doctype_fields.get_export_fields`)
|
||||||
|
* that uses `frappe.get_meta()` and `frappe.get_all(..., ignore_permissions=True)`.
|
||||||
|
* These bypass field-level role filtering and always return the complete list.
|
||||||
|
*
|
||||||
|
* The in-memory cache below is fine to key by doctype only (no user suffix
|
||||||
|
* needed) because the server now returns the same field list for everyone.
|
||||||
|
* sessionStorage is intentionally NOT used — a hard refresh always gets fresh
|
||||||
|
* data, avoiding the "need Ctrl+Shift+R" problem entirely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface DoctypeField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
fieldtype: string;
|
||||||
|
default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default fields per DocType ────────────────────────────────────────────────
|
||||||
|
const DEFAULT_FIELDS: Record<string, Set<string>> = {
|
||||||
|
Work_Order: new Set([
|
||||||
|
'name', 'asset', 'asset_name', 'work_order_type', 'company',
|
||||||
|
'department', 'repair_status', 'workflow_state', 'custom_priority_',
|
||||||
|
'creation', 'modified',
|
||||||
|
]),
|
||||||
|
Asset: new Set([
|
||||||
|
'name', 'asset_name', 'custom_serial_number', 'company',
|
||||||
|
'location', 'custom_device_status', 'modified',
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── In-memory cache (tab lifetime only, keyed by doctype) ────────────────────
|
||||||
|
// Safe to key by doctype only now because the server returns role-independent
|
||||||
|
// results. Clears automatically on page refresh — no stale data ever.
|
||||||
|
const memCache = new Map<string, DoctypeField[]>();
|
||||||
|
|
||||||
|
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useDoctypeFields(doctype: string) {
|
||||||
|
const [fields, setFields] = useState<DoctypeField[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctype) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
if (fetchingRef.current) return;
|
||||||
|
fetchingRef.current = true;
|
||||||
|
|
||||||
|
// ── In-memory cache hit ───────────────────────────────────────────────
|
||||||
|
if (memCache.has(doctype)) {
|
||||||
|
if (!cancelled) setFields(memCache.get(doctype)!);
|
||||||
|
fetchingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) { setLoading(true); setError(null); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Call the server-side whitelisted function ─────────────────────
|
||||||
|
// This uses frappe.get_meta() + ignore_permissions=True internally,
|
||||||
|
// so it returns ALL fields regardless of the current user's role.
|
||||||
|
const res = await fetch(
|
||||||
|
'/api/method/asset_lite.api.doctype_fields.get_export_fields',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ doctype }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Server returned ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.exc) {
|
||||||
|
throw new Error(data.exc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server returns: [{ fieldname, label, fieldtype }, ...]
|
||||||
|
const raw: { fieldname: string; label: string; fieldtype: string }[] =
|
||||||
|
data.message || [];
|
||||||
|
|
||||||
|
const defaultSet = DEFAULT_FIELDS[doctype];
|
||||||
|
|
||||||
|
const normalized: DoctypeField[] = raw.map((f, idx) => ({
|
||||||
|
key: f.fieldname,
|
||||||
|
label: f.label || f.fieldname,
|
||||||
|
fieldtype: f.fieldtype || 'Data',
|
||||||
|
default: defaultSet ? defaultSet.has(f.fieldname) : idx < 8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[useDoctypeFields] ✅ "${doctype}": ${normalized.length} fields from server`
|
||||||
|
);
|
||||||
|
|
||||||
|
memCache.set(doctype, normalized);
|
||||||
|
if (!cancelled) setFields(normalized);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useDoctypeFields] ❌', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch fields');
|
||||||
|
// Minimal fallback so the export modal isn't completely broken
|
||||||
|
setFields([
|
||||||
|
{ key: 'name', label: 'ID', fieldtype: 'Data', default: true },
|
||||||
|
{ key: 'creation', label: 'Created On', fieldtype: 'Datetime', default: false },
|
||||||
|
{ key: 'modified', label: 'Modified On', fieldtype: 'Datetime', default: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fetchingRef.current = false;
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [doctype]);
|
||||||
|
|
||||||
|
/** Force a re-fetch from the server (e.g. after DocType schema changes). */
|
||||||
|
const refetchFields = () => {
|
||||||
|
memCache.delete(doctype);
|
||||||
|
fetchingRef.current = false;
|
||||||
|
setFields([]);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { fields, loading, error, refetchFields };
|
||||||
|
}
|
||||||
231
pm_app/src/hooks/useFrappeFieldBehavior.ts
Normal file
231
pm_app/src/hooks/useFrappeFieldBehavior.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* useFrappeFieldBehavior Hook
|
||||||
|
*
|
||||||
|
* Integrates with existing forms to provide Frappe's dynamic field behavior:
|
||||||
|
* - depends_on (conditional visibility)
|
||||||
|
* - mandatory_depends_on (conditional mandatory)
|
||||||
|
* - read_only_depends_on (conditional read-only)
|
||||||
|
* - fetch_from (auto-fetch values)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { getFieldState, shouldShowField, isMandatory, isReadOnly, processFieldValue } = useFrappeFieldBehavior('Asset', doc);
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
import { FieldConfig, evaluateFrappeExpression, parseFetchFrom } from '../utils/frappeExpressionEvaluator';
|
||||||
|
|
||||||
|
interface FieldBehaviorState {
|
||||||
|
isVisible: boolean;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
isMandatory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFrappeFieldBehaviorResult {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
getFieldState: (fieldname: string) => FieldBehaviorState;
|
||||||
|
shouldShowField: (fieldname: string) => boolean;
|
||||||
|
isMandatory: (fieldname: string) => boolean;
|
||||||
|
isReadOnly: (fieldname: string) => boolean;
|
||||||
|
getFieldLabel: (fieldname: string) => string;
|
||||||
|
getFieldOptions: (fieldname: string) => string[];
|
||||||
|
getFetchFromValue: (fieldname: string, linkedDoc: Record<string, any> | null) => any;
|
||||||
|
validateMandatory: () => { valid: boolean; errors: Record<string, string> };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for doctype fields
|
||||||
|
const fieldCache: Record<string, FieldConfig[]> = {};
|
||||||
|
|
||||||
|
export function useFrappeFieldBehavior(
|
||||||
|
doctype: string,
|
||||||
|
doc: Record<string, any>
|
||||||
|
): UseFrappeFieldBehaviorResult {
|
||||||
|
const [fields, setFields] = useState<FieldConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch doctype field configuration
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctype) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if (fieldCache[doctype]) {
|
||||||
|
setFields(fieldCache[doctype]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFields = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Try to fetch from DocType
|
||||||
|
const response = await apiService.apiCall<any>(
|
||||||
|
`/api/method/frappe.client.get_doc?doctype=DocType&name=${encodeURIComponent(doctype)}`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response?.message?.fields) {
|
||||||
|
const fieldConfigs: FieldConfig[] = response.message.fields.map((f: any) => ({
|
||||||
|
fieldname: f.fieldname,
|
||||||
|
label: f.label,
|
||||||
|
fieldtype: f.fieldtype,
|
||||||
|
options: f.options,
|
||||||
|
reqd: f.reqd,
|
||||||
|
hidden: f.hidden,
|
||||||
|
read_only: f.read_only,
|
||||||
|
depends_on: f.depends_on,
|
||||||
|
mandatory_depends_on: f.mandatory_depends_on,
|
||||||
|
read_only_depends_on: f.read_only_depends_on,
|
||||||
|
fetch_from: f.fetch_from,
|
||||||
|
fetch_if_empty: f.fetch_if_empty,
|
||||||
|
default: f.default,
|
||||||
|
description: f.description,
|
||||||
|
in_list_view: f.in_list_view,
|
||||||
|
permlevel: f.permlevel,
|
||||||
|
allow_on_submit: f.allow_on_submit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
fieldCache[doctype] = fieldConfigs;
|
||||||
|
setFields(fieldConfigs);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn(`Could not fetch DocType meta for ${doctype}:`, err.message);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFields();
|
||||||
|
}, [doctype]);
|
||||||
|
|
||||||
|
// Create a map for quick field lookup
|
||||||
|
const fieldMap = useMemo(() => {
|
||||||
|
const map: Record<string, FieldConfig> = {};
|
||||||
|
fields.forEach(f => {
|
||||||
|
map[f.fieldname] = f;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Get complete field state
|
||||||
|
const getFieldState = useCallback((fieldname: string): FieldBehaviorState => {
|
||||||
|
const config = fieldMap[fieldname];
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
// Field not found in config - return defaults
|
||||||
|
return { isVisible: true, isReadOnly: false, isMandatory: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base visibility
|
||||||
|
let isVisible = !(config.hidden === 1 || config.hidden === true);
|
||||||
|
|
||||||
|
// Evaluate depends_on
|
||||||
|
if (config.depends_on && isVisible) {
|
||||||
|
isVisible = evaluateFrappeExpression(config.depends_on, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base read-only
|
||||||
|
let isReadOnly = config.read_only === 1 || config.read_only === true;
|
||||||
|
|
||||||
|
// Evaluate read_only_depends_on
|
||||||
|
if (config.read_only_depends_on) {
|
||||||
|
isReadOnly = isReadOnly || evaluateFrappeExpression(config.read_only_depends_on, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base mandatory
|
||||||
|
let isMandatory = config.reqd === 1 || config.reqd === true;
|
||||||
|
|
||||||
|
// Evaluate mandatory_depends_on
|
||||||
|
if (config.mandatory_depends_on) {
|
||||||
|
isMandatory = isMandatory || evaluateFrappeExpression(config.mandatory_depends_on, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isVisible, isReadOnly, isMandatory };
|
||||||
|
}, [fieldMap, doc]);
|
||||||
|
|
||||||
|
// Convenience methods
|
||||||
|
const shouldShowField = useCallback((fieldname: string): boolean => {
|
||||||
|
return getFieldState(fieldname).isVisible;
|
||||||
|
}, [getFieldState]);
|
||||||
|
|
||||||
|
const isMandatory = useCallback((fieldname: string): boolean => {
|
||||||
|
const state = getFieldState(fieldname);
|
||||||
|
return state.isVisible && state.isMandatory;
|
||||||
|
}, [getFieldState]);
|
||||||
|
|
||||||
|
const isReadOnly = useCallback((fieldname: string): boolean => {
|
||||||
|
return getFieldState(fieldname).isReadOnly;
|
||||||
|
}, [getFieldState]);
|
||||||
|
|
||||||
|
const getFieldLabel = useCallback((fieldname: string): string => {
|
||||||
|
const config = fieldMap[fieldname];
|
||||||
|
return config?.label || fieldname;
|
||||||
|
}, [fieldMap]);
|
||||||
|
|
||||||
|
const getFieldOptions = useCallback((fieldname: string): string[] => {
|
||||||
|
const config = fieldMap[fieldname];
|
||||||
|
if (!config?.options) return [];
|
||||||
|
|
||||||
|
if (config.fieldtype === 'Select') {
|
||||||
|
return config.options.split('\n').filter(opt => opt.trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [fieldMap]);
|
||||||
|
|
||||||
|
// Get value from linked document based on fetch_from
|
||||||
|
const getFetchFromValue = useCallback((fieldname: string, linkedDoc: Record<string, any> | null): any => {
|
||||||
|
const config = fieldMap[fieldname];
|
||||||
|
if (!config?.fetch_from || !linkedDoc) return undefined;
|
||||||
|
|
||||||
|
const parsed = parseFetchFrom(config.fetch_from);
|
||||||
|
if (!parsed) return undefined;
|
||||||
|
|
||||||
|
// The linkedDoc should have the target field
|
||||||
|
return linkedDoc[parsed.targetField];
|
||||||
|
}, [fieldMap]);
|
||||||
|
|
||||||
|
// Validate all mandatory fields
|
||||||
|
const validateMandatory = useCallback((): { valid: boolean; errors: Record<string, string> } => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
const state = getFieldState(field.fieldname);
|
||||||
|
|
||||||
|
if (state.isVisible && state.isMandatory) {
|
||||||
|
const value = doc[field.fieldname];
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
errors[field.fieldname] = `${field.label || field.fieldname} is required`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: Object.keys(errors).length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}, [fields, doc, getFieldState]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fields,
|
||||||
|
getFieldState,
|
||||||
|
shouldShowField,
|
||||||
|
isMandatory,
|
||||||
|
isReadOnly,
|
||||||
|
getFieldLabel,
|
||||||
|
getFieldOptions,
|
||||||
|
getFetchFromValue,
|
||||||
|
validateMandatory
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useFrappeFieldBehavior;
|
||||||
|
|
||||||
44
pm_app/src/hooks/useListPageSelection.ts
Normal file
44
pm_app/src/hooks/useListPageSelection.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row selection for paginated list + export. Selection is scoped to the current page:
|
||||||
|
* "select all" replaces the set with the current page's names; changing `resetKey` clears selection.
|
||||||
|
*/
|
||||||
|
export function useListPageSelection<T extends { name: string }>(pageRows: T[], resetKey: string | number) {
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
const toggleRow = useCallback((name: string) => {
|
||||||
|
setSelectedRows(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) next.delete(name);
|
||||||
|
else next.add(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleAllOnPage = useCallback(() => {
|
||||||
|
setSelectedRows(prev => {
|
||||||
|
const pageIds = pageRows.map(r => r.name);
|
||||||
|
if (pageIds.length === 0) return new Set();
|
||||||
|
const allOnPage = pageIds.every(id => prev.has(id));
|
||||||
|
if (allOnPage) return new Set();
|
||||||
|
return new Set(pageIds);
|
||||||
|
});
|
||||||
|
}, [pageRows]);
|
||||||
|
|
||||||
|
const allOnPageSelected = pageRows.length > 0 && pageRows.every(r => selectedRows.has(r.name));
|
||||||
|
const someOnPageSelected =
|
||||||
|
pageRows.some(r => selectedRows.has(r.name)) && !allOnPageSelected;
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
};
|
||||||
|
}
|
||||||
470
pm_app/src/hooks/useProject.ts
Normal file
470
pm_app/src/hooks/useProject.ts
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import projectService, {
|
||||||
|
type Project,
|
||||||
|
type Task,
|
||||||
|
type Timesheet,
|
||||||
|
type ProjectTemplate,
|
||||||
|
type ActivityType,
|
||||||
|
type ProjectListParams,
|
||||||
|
} from '../services/projectService';
|
||||||
|
|
||||||
|
/** Normalized key so pagination fields always affect deps (JSON.stringify drops `undefined`). */
|
||||||
|
function listQueryKey(p: ProjectListParams): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
filters: p.filters ?? {},
|
||||||
|
appendFilters: p.appendFilters ?? [],
|
||||||
|
fields: p.fields ?? null,
|
||||||
|
limit_start: p.limit_start ?? 0,
|
||||||
|
limit_page_length: p.limit_page_length ?? 20,
|
||||||
|
order_by: p.order_by ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project list ────────────────────────────────────────────────────────────
|
||||||
|
export const useProjectList = (params: ProjectListParams = {}) => {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const paramsKey = listQueryKey(params);
|
||||||
|
const fetchSeqRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [response, count] = await Promise.all([
|
||||||
|
projectService.getProjects(params),
|
||||||
|
projectService.getProjectCount(params.filters),
|
||||||
|
]);
|
||||||
|
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||||
|
setProjects(response.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch projects');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled && reqId === fetchSeqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [response, count] = await Promise.all([
|
||||||
|
projectService.getProjects(params),
|
||||||
|
projectService.getProjectCount(params.filters),
|
||||||
|
]);
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setProjects(response.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch projects');
|
||||||
|
} finally {
|
||||||
|
if (reqId === fetchSeqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
return { projects, loading, error, totalCount, refetch };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Project detail ──────────────────────────────────────────────────────────
|
||||||
|
export const useProjectDetails = (projectName: string | null) => {
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchProject = useCallback(async () => {
|
||||||
|
if (!projectName) { setProject(null); return; }
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setProject(await projectService.getProject(projectName));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch project');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectName]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchProject(); }, [fetchProject]);
|
||||||
|
return { project, loading, error, refetch: fetchProject };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Task list (generic, filterable by project) ──────────────────────────────
|
||||||
|
export const useTaskList = (params: ProjectListParams = {}) => {
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const paramsKey = listQueryKey(params);
|
||||||
|
const fetchSeqRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [response, count] = await Promise.all([
|
||||||
|
projectService.getTasks(params),
|
||||||
|
projectService.getTaskCount(params.filters),
|
||||||
|
]);
|
||||||
|
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||||
|
setTasks(response.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch tasks');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled && reqId === fetchSeqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [response, count] = await Promise.all([
|
||||||
|
projectService.getTasks(params),
|
||||||
|
projectService.getTaskCount(params.filters),
|
||||||
|
]);
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setTasks(response.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch tasks');
|
||||||
|
} finally {
|
||||||
|
if (reqId === fetchSeqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
return { tasks, loading, error, totalCount, refetch };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tasks for a project ─────────────────────────────────────────────────────
|
||||||
|
export const useProjectTasks = (projectName: string | null) => {
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
if (!projectName) { setTasks([]); return; }
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const { data } = await projectService.getTasksForProject(projectName);
|
||||||
|
setTasks(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch tasks');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectName]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||||
|
return { tasks, loading, error, refetch: fetchTasks };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Task detail ─────────────────────────────────────────────────────────────
|
||||||
|
export const useTaskDetails = (taskName: string | null) => {
|
||||||
|
const [task, setTask] = useState<Task | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTask = useCallback(async () => {
|
||||||
|
if (!taskName) { setTask(null); return; }
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setTask(await projectService.getTask(taskName));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch task');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [taskName]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTask(); }, [fetchTask]);
|
||||||
|
return { task, loading, error, refetch: fetchTask };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Timesheet list (generic) ────────────────────────────────────────────────
|
||||||
|
export const useTimesheetList = (params: ProjectListParams = {}) => {
|
||||||
|
const [timesheets, setTimesheets] = useState<Timesheet[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const paramsKey = listQueryKey(params);
|
||||||
|
const fetchSeqRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [response, count] = await Promise.all([
|
||||||
|
projectService.getTimesheets(params),
|
||||||
|
projectService.getTimesheetCount(params.filters || {}, params.appendFilters || []),
|
||||||
|
]);
|
||||||
|
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||||
|
setTimesheets(response.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch timesheets');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled && reqId === fetchSeqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [response, count] = await Promise.all([
|
||||||
|
projectService.getTimesheets(params),
|
||||||
|
projectService.getTimesheetCount(params.filters || {}, params.appendFilters || []),
|
||||||
|
]);
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setTimesheets(response.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch timesheets');
|
||||||
|
} finally {
|
||||||
|
if (reqId === fetchSeqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
return { timesheets, loading, error, totalCount, refetch };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Timesheets for a project ────────────────────────────────────────────────
|
||||||
|
export const useProjectTimesheets = (projectName: string | null) => {
|
||||||
|
const [timesheets, setTimesheets] = useState<Timesheet[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTimesheets = useCallback(async () => {
|
||||||
|
if (!projectName) { setTimesheets([]); return; }
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const { data } = await projectService.getTimesheetsForProject(projectName);
|
||||||
|
setTimesheets(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch timesheets');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectName]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTimesheets(); }, [fetchTimesheets]);
|
||||||
|
return { timesheets, loading, error, refetch: fetchTimesheets };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Timesheet detail ────────────────────────────────────────────────────────
|
||||||
|
export const useTimesheetDetails = (name: string | null) => {
|
||||||
|
const [timesheet, setTimesheet] = useState<Timesheet | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTimesheet = useCallback(async () => {
|
||||||
|
if (!name) { setTimesheet(null); return; }
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setTimesheet(await projectService.getTimesheet(name));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch timesheet');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTimesheet(); }, [fetchTimesheet]);
|
||||||
|
return { timesheet, loading, error, refetch: fetchTimesheet };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Project Templates ────────────────────────────────────────────────────────
|
||||||
|
export const useProjectTemplates = (params: ProjectListParams = {}) => {
|
||||||
|
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const paramsKey = listQueryKey(params);
|
||||||
|
const fetchSeqRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [r, count] = await Promise.all([
|
||||||
|
projectService.getProjectTemplates(params),
|
||||||
|
projectService.getProjectTemplateCount(params.filters || {}),
|
||||||
|
]);
|
||||||
|
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||||
|
setTemplates(r.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch { /* silent */ } finally { if (!cancelled && reqId === fetchSeqRef.current) setLoading(false); }
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [r, count] = await Promise.all([
|
||||||
|
projectService.getProjectTemplates(params),
|
||||||
|
projectService.getProjectTemplateCount(params.filters || {}),
|
||||||
|
]);
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setTemplates(r.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch { /* silent */ } finally { if (reqId === fetchSeqRef.current) setLoading(false); }
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
return { templates, loading, totalCount, refetch };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProjectTemplateDetails = (name: string | null) => {
|
||||||
|
const [template, setTemplate] = useState<ProjectTemplate | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTemplate = useCallback(async () => {
|
||||||
|
if (!name) { setTemplate(null); return; }
|
||||||
|
try {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
setTemplate(await projectService.getProjectTemplate(name));
|
||||||
|
} catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch template'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTemplate(); }, [fetchTemplate]);
|
||||||
|
return { template, loading, error, refetch: fetchTemplate };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Activity Types ───────────────────────────────────────────────────────────
|
||||||
|
export const useActivityTypeList = (params: ProjectListParams = {}) => {
|
||||||
|
const [activityTypes, setActivityTypes] = useState<ActivityType[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const paramsKey = listQueryKey(params);
|
||||||
|
const fetchSeqRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [r, count] = await Promise.all([
|
||||||
|
projectService.getActivityTypes(params),
|
||||||
|
projectService.getActivityTypeCount(params.filters || {}),
|
||||||
|
]);
|
||||||
|
if (cancelled || reqId !== fetchSeqRef.current) return;
|
||||||
|
setActivityTypes(r.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch { /* silent */ } finally { if (!cancelled && reqId === fetchSeqRef.current) setLoading(false); }
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [r, count] = await Promise.all([
|
||||||
|
projectService.getActivityTypes(params),
|
||||||
|
projectService.getActivityTypeCount(params.filters || {}),
|
||||||
|
]);
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setActivityTypes(r.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch { /* silent */ } finally { if (reqId === fetchSeqRef.current) setLoading(false); }
|
||||||
|
}, [paramsKey]);
|
||||||
|
|
||||||
|
return { activityTypes, loading, totalCount, refetch };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useActivityTypeDetails = (name: string | null) => {
|
||||||
|
const [activityType, setActivityType] = useState<ActivityType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchActivityType = useCallback(async () => {
|
||||||
|
if (!name) { setActivityType(null); return; }
|
||||||
|
try {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
setActivityType(await projectService.getActivityType(name));
|
||||||
|
} catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch activity type'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchActivityType(); }, [fetchActivityType]);
|
||||||
|
return { activityType, loading, error, refetch: fetchActivityType };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Mutations ───────────────────────────────────────────────────────────────
|
||||||
|
export const useProjectMutations = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const run = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Operation failed';
|
||||||
|
setError(msg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createProject: (data: Partial<Project>) => run(() => projectService.createProject(data)),
|
||||||
|
updateProject: (name: string, data: Partial<Project>) => run(() => projectService.updateProject(name, data)),
|
||||||
|
createTask: (data: Partial<Task>) => run(() => projectService.createTask(data)),
|
||||||
|
updateTask: (name: string, data: Partial<Task>) => run(() => projectService.updateTask(name, data)),
|
||||||
|
createTimesheet: (data: Partial<Timesheet>) => run(() => projectService.createTimesheet(data)),
|
||||||
|
updateTimesheet: (name: string, data: Partial<Timesheet>) => run(() => projectService.updateTimesheet(name, data)),
|
||||||
|
submitTimesheet: (name: string) => run(() => projectService.submitTimesheet(name)),
|
||||||
|
cancelTimesheet: (name: string) => run(() => projectService.cancelTimesheet(name)),
|
||||||
|
createProjectTemplate: (data: Partial<ProjectTemplate>) => run(() => projectService.createProjectTemplate(data)),
|
||||||
|
updateProjectTemplate: (name: string, data: Partial<ProjectTemplate>) => run(() => projectService.updateProjectTemplate(name, data)),
|
||||||
|
createActivityType: (data: Partial<ActivityType>) => run(() => projectService.createActivityType(data)),
|
||||||
|
updateActivityType: (name: string, data: Partial<ActivityType>) => run(() => projectService.updateActivityType(name, data)),
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
188
pm_app/src/hooks/useUserPermissions.ts
Normal file
188
pm_app/src/hooks/useUserPermissions.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
interface RestrictionInfo {
|
||||||
|
field: string;
|
||||||
|
values: string[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionsState {
|
||||||
|
isAdmin: boolean;
|
||||||
|
restrictions: Record<string, RestrictionInfo>;
|
||||||
|
permissionFilters: Record<string, any>;
|
||||||
|
targetDoctype: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic hook for user permissions - works with ANY doctype
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { permissionFilters, restrictions } = useUserPermissions('Asset');
|
||||||
|
* const { permissionFilters, restrictions } = useUserPermissions('Work Order');
|
||||||
|
* const { permissionFilters, restrictions } = useUserPermissions('Project');
|
||||||
|
*/
|
||||||
|
export const useUserPermissions = (targetDoctype: string = 'Asset') => {
|
||||||
|
const [state, setState] = useState<PermissionsState>({
|
||||||
|
isAdmin: false,
|
||||||
|
restrictions: {},
|
||||||
|
permissionFilters: {},
|
||||||
|
targetDoctype,
|
||||||
|
loading: true,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchPermissions = useCallback(async (doctype?: string) => {
|
||||||
|
const dt = doctype || targetDoctype;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null, targetDoctype: dt }));
|
||||||
|
|
||||||
|
const response = await apiService.getPermissionFilters(dt);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isAdmin: response.is_admin,
|
||||||
|
restrictions: response.restrictions || {},
|
||||||
|
permissionFilters: response.filters || {},
|
||||||
|
targetDoctype: dt,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching permissions for ${dt}:`, err);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to fetch permissions'
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [targetDoctype]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchPermissions]);
|
||||||
|
|
||||||
|
// Get allowed values for a permission type (e.g., "Company", "Location")
|
||||||
|
const getAllowedValues = useCallback((permissionType: string): string[] => {
|
||||||
|
return state.restrictions[permissionType]?.values || [];
|
||||||
|
}, [state.restrictions]);
|
||||||
|
|
||||||
|
// Check if user has restriction on a permission type
|
||||||
|
const hasRestriction = useCallback((permissionType: string): boolean => {
|
||||||
|
if (state.isAdmin) return false;
|
||||||
|
return !!state.restrictions[permissionType];
|
||||||
|
}, [state.isAdmin, state.restrictions]);
|
||||||
|
|
||||||
|
// Check if any restrictions exist
|
||||||
|
const hasAnyRestrictions = useMemo(() => {
|
||||||
|
return !state.isAdmin && Object.keys(state.restrictions).length > 0;
|
||||||
|
}, [state.isAdmin, state.restrictions]);
|
||||||
|
|
||||||
|
// Merge user filters with permission filters
|
||||||
|
const mergeFilters = useCallback((userFilters: Record<string, any>): Record<string, any> => {
|
||||||
|
if (state.isAdmin) return userFilters;
|
||||||
|
|
||||||
|
const merged = { ...userFilters };
|
||||||
|
|
||||||
|
for (const [field, value] of Object.entries(state.permissionFilters)) {
|
||||||
|
if (!merged[field]) {
|
||||||
|
merged[field] = value;
|
||||||
|
} else if (Array.isArray(value) && value[0] === 'in') {
|
||||||
|
const permittedValues = value[1] as string[];
|
||||||
|
if (typeof merged[field] === 'string' && !permittedValues.includes(merged[field])) {
|
||||||
|
merged[field] = ['in', []]; // Return empty - value not permitted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}, [state.isAdmin, state.permissionFilters]);
|
||||||
|
|
||||||
|
// Get summary of restrictions for display
|
||||||
|
const restrictionsList = useMemo(() => {
|
||||||
|
return Object.entries(state.restrictions).map(([type, info]) => ({
|
||||||
|
type,
|
||||||
|
field: info.field,
|
||||||
|
values: info.values,
|
||||||
|
count: info.count
|
||||||
|
}));
|
||||||
|
}, [state.restrictions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
refetch: fetchPermissions,
|
||||||
|
switchDoctype: fetchPermissions,
|
||||||
|
getAllowedValues,
|
||||||
|
hasRestriction,
|
||||||
|
hasAnyRestrictions,
|
||||||
|
mergeFilters,
|
||||||
|
restrictionsList
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check access to a specific document
|
||||||
|
*/
|
||||||
|
export const useDocumentAccess = (doctype: string | null, docname: string | null) => {
|
||||||
|
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctype || !docname) {
|
||||||
|
setHasAccess(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiService.checkDocumentAccess(doctype, docname);
|
||||||
|
setHasAccess(response.has_access);
|
||||||
|
if (!response.has_access && response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to check access');
|
||||||
|
setHasAccess(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
}, [doctype, docname]);
|
||||||
|
|
||||||
|
return { hasAccess, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get user's default values
|
||||||
|
*/
|
||||||
|
export const useUserDefaults = () => {
|
||||||
|
const [defaults, setDefaults] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.getUserDefaults();
|
||||||
|
setDefaults(response.defaults || {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch user defaults:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetch();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { defaults, loading, getDefault: (type: string) => defaults[type] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUserPermissions;
|
||||||
204
pm_app/src/hooks/useWorkflow.ts
Normal file
204
pm_app/src/hooks/useWorkflow.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import workflowService, {
|
||||||
|
type WorkflowTransition,
|
||||||
|
type WorkflowInfo,
|
||||||
|
getWorkflowStateStyle,
|
||||||
|
getActionButtonStyle,
|
||||||
|
getActionIcon,
|
||||||
|
hasWorkflowFullAccess,
|
||||||
|
} from '../services/workflowService';
|
||||||
|
|
||||||
|
interface UseWorkflowOptions {
|
||||||
|
doctype: string;
|
||||||
|
docname: string | null;
|
||||||
|
workflowState?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
docData?: Record<string, any>; // Added: Document data for condition evaluation
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseWorkflowReturn {
|
||||||
|
// State
|
||||||
|
transitions: WorkflowTransition[];
|
||||||
|
workflowInfo: WorkflowInfo | null;
|
||||||
|
userRoles: string[];
|
||||||
|
currentUser: string;
|
||||||
|
isSystemManager: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
actionLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
applyAction: (action: string, nextState?: string) => Promise<boolean>;
|
||||||
|
refreshTransitions: () => Promise<void>;
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
getStateStyle: (state: string) => { bg: string; text: string; border: string };
|
||||||
|
getButtonStyle: (action: string) => string;
|
||||||
|
getIcon: (action: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkflow = ({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
workflowState,
|
||||||
|
enabled = true,
|
||||||
|
docData, // Added: Document data for condition evaluation
|
||||||
|
}: UseWorkflowOptions): UseWorkflowReturn => {
|
||||||
|
const [transitions, setTransitions] = useState<WorkflowTransition[]>([]);
|
||||||
|
const [workflowInfo, setWorkflowInfo] = useState<WorkflowInfo | null>(null);
|
||||||
|
const [userRoles, setUserRoles] = useState<string[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<string>('');
|
||||||
|
const [isSystemManagerUser, setIsSystemManagerUser] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [canEdit, setCanEdit] = useState(true);
|
||||||
|
|
||||||
|
// Fetch workflow info on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const fetchWorkflowInfo = async () => {
|
||||||
|
try {
|
||||||
|
const info = await workflowService.getWorkflowInfo(doctype);
|
||||||
|
setWorkflowInfo(info);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching workflow info:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchWorkflowInfo();
|
||||||
|
}, [doctype, enabled]);
|
||||||
|
|
||||||
|
// Fetch user roles, current user, and check System Manager
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const fetchUserInfo = async () => {
|
||||||
|
try {
|
||||||
|
const [roles, user, isSysManager, fullAccess] = await Promise.all([
|
||||||
|
workflowService.getCurrentUserRoles(),
|
||||||
|
workflowService.getCurrentUser(),
|
||||||
|
workflowService.isSystemManager(),
|
||||||
|
hasWorkflowFullAccess(),
|
||||||
|
]);
|
||||||
|
setUserRoles(roles);
|
||||||
|
setCurrentUser(user);
|
||||||
|
setIsSystemManagerUser(isSysManager || fullAccess);
|
||||||
|
|
||||||
|
if (fullAccess) setCanEdit(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching user info:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
// Fetch available transitions when docname, workflowState, or docData changes
|
||||||
|
const refreshTransitions = useCallback(async () => {
|
||||||
|
if (!docname || !enabled) {
|
||||||
|
setTransitions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass document data for condition evaluation
|
||||||
|
const availableTransitions = await workflowService.getWorkflowTransitions(
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
workflowState,
|
||||||
|
docData // Pass document data
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[useWorkflow] Available transitions:', availableTransitions);
|
||||||
|
setTransitions(availableTransitions);
|
||||||
|
|
||||||
|
// Check if user can edit (System Manager always can)
|
||||||
|
if (workflowState) {
|
||||||
|
const canUserEdit = await workflowService.canUserEditDocument(doctype, docname, workflowState);
|
||||||
|
setCanEdit(canUserEdit);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching transitions:', err);
|
||||||
|
setError('Failed to load workflow actions');
|
||||||
|
setTransitions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [doctype, docname, workflowState, enabled, docData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshTransitions();
|
||||||
|
}, [refreshTransitions]);
|
||||||
|
|
||||||
|
// Apply workflow action
|
||||||
|
const applyAction = useCallback(async (action: string, nextState?: string): Promise<boolean> => {
|
||||||
|
if (!docname) {
|
||||||
|
setError('Document not saved yet');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass nextState for System Manager force update if needed
|
||||||
|
await workflowService.applyWorkflowAction(doctype, docname, action, nextState);
|
||||||
|
|
||||||
|
// Refresh transitions after action
|
||||||
|
await refreshTransitions();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error applying workflow action:', err);
|
||||||
|
|
||||||
|
// Extract error message
|
||||||
|
let errorMessage = 'Failed to apply action';
|
||||||
|
if (err.message) {
|
||||||
|
errorMessage = err.message;
|
||||||
|
} else if (err._server_messages) {
|
||||||
|
try {
|
||||||
|
const serverMessages = JSON.parse(err._server_messages);
|
||||||
|
errorMessage = serverMessages.map((m: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(m).message;
|
||||||
|
} catch {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}).join('\n');
|
||||||
|
} catch {
|
||||||
|
errorMessage = err._server_messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}, [doctype, docname, refreshTransitions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transitions,
|
||||||
|
workflowInfo,
|
||||||
|
userRoles,
|
||||||
|
currentUser,
|
||||||
|
isSystemManager: isSystemManagerUser,
|
||||||
|
loading,
|
||||||
|
actionLoading,
|
||||||
|
error,
|
||||||
|
canEdit,
|
||||||
|
applyAction,
|
||||||
|
refreshTransitions,
|
||||||
|
getStateStyle: getWorkflowStateStyle,
|
||||||
|
getButtonStyle: getActionButtonStyle,
|
||||||
|
getIcon: getActionIcon,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useWorkflow;
|
||||||
70
pm_app/src/i18n.ts
Normal file
70
pm_app/src/i18n.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
|
import enTranslation from './locales/en/translation.json';
|
||||||
|
import arTranslation from './locales/ar/translation.json';
|
||||||
|
import { getFrappeTranslations } from './services/translationService';
|
||||||
|
|
||||||
|
// Initialize i18n with static translations first (fallback)
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
translation: enTranslation
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
translation: arTranslation
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fallbackLng: 'en',
|
||||||
|
defaultNS: 'translation',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false
|
||||||
|
},
|
||||||
|
detection: {
|
||||||
|
order: ['localStorage', 'navigator'],
|
||||||
|
caches: ['localStorage']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load translations from Frappe and merge with static translations
|
||||||
|
export async function loadFrappeTranslations() {
|
||||||
|
try {
|
||||||
|
// Only load translations if user is logged in (to avoid 403 errors)
|
||||||
|
const user = localStorage.getItem('user');
|
||||||
|
if (!user) {
|
||||||
|
// User not logged in yet, skip loading translations from Frappe
|
||||||
|
// They will be loaded after login
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load English translations from Frappe
|
||||||
|
const enFrappeTranslations = await getFrappeTranslations('en');
|
||||||
|
if (Object.keys(enFrappeTranslations).length > 0) {
|
||||||
|
i18n.addResourceBundle('en', 'translation', enFrappeTranslations, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Arabic translations from Frappe
|
||||||
|
const arFrappeTranslations = await getFrappeTranslations('ar');
|
||||||
|
if (Object.keys(arFrappeTranslations).length > 0) {
|
||||||
|
i18n.addResourceBundle('ar', 'translation', arFrappeTranslations, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ Translations loaded from Frappe');
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail - will use static translations
|
||||||
|
console.warn('⚠ Could not load translations from Frappe, using static translations:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-load translations when i18n is ready (only if user is logged in)
|
||||||
|
i18n.on('initialized', () => {
|
||||||
|
loadFrappeTranslations();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
|
|
||||||
|
|
||||||
91
pm_app/src/index.css
Normal file
91
pm_app/src/index.css
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.perspective-1000 {
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-style-3d {
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backface-hidden {
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotate-y-180 {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar Styles */
|
||||||
|
@layer base {
|
||||||
|
/* Webkit browsers (Chrome, Safari, Edge) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(209, 213, 219); /* gray-300 */
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(156, 163, 175); /* gray-400 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode scrollbar */
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(75, 85, 99); /* gray-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(107, 114, 128); /* gray-500 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(209, 213, 219) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: rgb(75, 85, 99) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL Support */
|
||||||
|
[dir="rtl"] {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] {
|
||||||
|
direction: ltr;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL spacing utilities */
|
||||||
|
.rtl .ml-auto {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rtl .mr-auto {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL flex utilities */
|
||||||
|
.rtl .flex-row-reverse {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
1653
pm_app/src/locales/ar/translation.json
Normal file
1653
pm_app/src/locales/ar/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
2039
pm_app/src/locales/en/translation.json
Normal file
2039
pm_app/src/locales/en/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
17
pm_app/src/main.tsx
Normal file
17
pm_app/src/main.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import './i18n'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
import { LanguageProvider } from './contexts/LanguageContext'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<LanguageProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</LanguageProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
232
pm_app/src/pages/ActivityTypeDetail.tsx
Normal file
232
pm_app/src/pages/ActivityTypeDetail.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaArrowLeft, FaSave, FaEdit, FaTimes, FaCheckCircle, FaTimesCircle, FaSpinner } from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import { useActivityTypeDetails, useProjectMutations } from '../hooks/useProject';
|
||||||
|
import type { ActivityType } from '../services/projectService';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
|
||||||
|
// Frappe Activity Type:
|
||||||
|
// autoname = "field:activity_type" → name column = activity_type value
|
||||||
|
// Fields: activity_type (Data, mandatory), billing_rate (Currency), costing_rate (Currency), disabled (Check)
|
||||||
|
|
||||||
|
const ActivityTypeDetail: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { activityTypeName } = useParams<{ activityTypeName: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = activityTypeName === 'new';
|
||||||
|
|
||||||
|
const { activityType, loading, error, refetch } = useActivityTypeDetails(isNew ? null : (activityTypeName || null));
|
||||||
|
const { createActivityType, updateActivityType, loading: saving } = useProjectMutations();
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Partial<ActivityType>>({
|
||||||
|
activity_type: '',
|
||||||
|
billing_rate: undefined,
|
||||||
|
costing_rate: undefined,
|
||||||
|
disabled: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activityType && !isNew) {
|
||||||
|
setForm({
|
||||||
|
activity_type: activityType.activity_type || activityType.name || '',
|
||||||
|
billing_rate: activityType.billing_rate,
|
||||||
|
costing_rate: activityType.costing_rate,
|
||||||
|
disabled: activityType.disabled ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activityType, isNew]);
|
||||||
|
|
||||||
|
const set = (k: keyof ActivityType, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.activity_type?.trim()) { toast.error('Activity Type name is required'); return; }
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
// Frappe uses autoname = "field:activity_type" — send activity_type field
|
||||||
|
// Frappe auto-sets name = activity_type value
|
||||||
|
const payload: Partial<ActivityType> = {
|
||||||
|
activity_type: form.activity_type!.trim(),
|
||||||
|
...(form.billing_rate !== undefined ? { billing_rate: form.billing_rate } : {}),
|
||||||
|
...(form.costing_rate !== undefined ? { costing_rate: form.costing_rate } : {}),
|
||||||
|
disabled: form.disabled ?? 0,
|
||||||
|
};
|
||||||
|
const created = await createActivityType(payload);
|
||||||
|
toast.success('Activity Type created', { icon: <FaCheckCircle /> });
|
||||||
|
navigate(`/projects/activity-types/${encodeURIComponent(created.name)}`);
|
||||||
|
} else {
|
||||||
|
// For update, only send mutable fields (not activity_type/name which can't change)
|
||||||
|
const payload: Partial<ActivityType> = {
|
||||||
|
...(form.billing_rate !== undefined ? { billing_rate: form.billing_rate } : {}),
|
||||||
|
...(form.costing_rate !== undefined ? { costing_rate: form.costing_rate } : {}),
|
||||||
|
disabled: form.disabled ?? 0,
|
||||||
|
};
|
||||||
|
await updateActivityType(activityTypeName!, payload);
|
||||||
|
toast.success('Activity Type updated', { icon: <FaCheckCircle /> });
|
||||||
|
setIsEditing(false);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('common.error'), { icon: <FaTimesCircle /> });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (activityType) {
|
||||||
|
setForm({
|
||||||
|
activity_type: activityType.activity_type || activityType.name || '',
|
||||||
|
billing_rate: activityType.billing_rate,
|
||||||
|
costing_rate: activityType.costing_rate,
|
||||||
|
disabled: activityType.disabled ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls = (ed: boolean) =>
|
||||||
|
`w-full px-3 py-2 text-sm border rounded-lg ${ed
|
||||||
|
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-teal-400'
|
||||||
|
: 'border-transparent bg-gray-50 dark:bg-gray-800 text-gray-800 dark:text-gray-200 cursor-default'}`;
|
||||||
|
|
||||||
|
const editable = isNew || isEditing;
|
||||||
|
|
||||||
|
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-teal-500 text-3xl" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3500} />
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-6">
|
||||||
|
<button onClick={() => navigate('/projects')} className="text-gray-500 hover:text-indigo-600 dark:text-gray-400">{t('projects.moduleTitle')}</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<button onClick={() => navigate('/projects/activity-types')} className="text-gray-500 hover:text-teal-600 dark:text-gray-400">{t('projects.activityTypeDoctype')}</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{isNew ? t('projects.newActivityType') : (activityType?.activity_type || activityType?.name || activityTypeName)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/projects/activity-types')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"><FaArrowLeft /></button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{isNew ? t('projects.newActivityType') : (form.activity_type || activityTypeName)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isNew && !isEditing && (
|
||||||
|
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-teal-500 text-teal-600 dark:text-teal-400 rounded-lg hover:bg-teal-50 dark:hover:bg-teal-900/20 text-sm">
|
||||||
|
<FaEdit /> {t('common.edit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 text-sm">
|
||||||
|
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}
|
||||||
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
{!isNew && (
|
||||||
|
<button onClick={handleCancel} className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 text-sm"><FaTimes /></button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !isNew && (
|
||||||
|
<div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded text-red-700 dark:text-red-300 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Activity Type name → maps to `activity_type` field in Frappe */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
Activity Type *
|
||||||
|
{isNew && <span className="ml-1 text-gray-400 font-normal normal-case text-xs">(becomes the record name)</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.activity_type || ''}
|
||||||
|
onChange={e => set('activity_type', e.target.value)}
|
||||||
|
disabled={!isNew}
|
||||||
|
className={inputCls(isNew)}
|
||||||
|
placeholder="e.g. Design, Development, Testing..."
|
||||||
|
/>
|
||||||
|
{!isNew && <p className="text-xs text-gray-400 mt-1">Activity Type name cannot be changed after creation.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Billing Rate — standard Frappe field is `billing_rate` */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Billing Rate</label>
|
||||||
|
<input
|
||||||
|
type="number" min={0} step={0.01}
|
||||||
|
value={form.billing_rate ?? ''}
|
||||||
|
onChange={e => set('billing_rate', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||||
|
disabled={!editable}
|
||||||
|
className={inputCls(editable)}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Costing Rate — standard Frappe field is `costing_rate` */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Costing Rate</label>
|
||||||
|
<input
|
||||||
|
type="number" min={0} step={0.01}
|
||||||
|
value={form.costing_rate ?? ''}
|
||||||
|
onChange={e => set('costing_rate', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||||
|
disabled={!editable}
|
||||||
|
className={inputCls(editable)}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disabled */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.disabled}
|
||||||
|
onChange={e => set('disabled', e.target.checked ? 1 : 0)}
|
||||||
|
disabled={!editable}
|
||||||
|
className="w-4 h-4 text-red-500 rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Disabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
{!isNew && activityType && (
|
||||||
|
<div className="pt-4 border-t border-gray-100 dark:border-gray-700 grid grid-cols-2 gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div><span className="font-medium block">Created</span>{activityType.creation ? new Date(activityType.creation).toLocaleString() : '-'}</div>
|
||||||
|
<div><span className="font-medium block">Modified</span>{activityType.modified ? new Date(activityType.modified).toLocaleString() : '-'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<div className="pt-4">
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Activity Type"
|
||||||
|
docname={activityType?.name || activityTypeName || ''}
|
||||||
|
creationDate={activityType?.creation}
|
||||||
|
createdBy={activityType?.owner}
|
||||||
|
compact={false}
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityTypeDetail;
|
||||||
230
pm_app/src/pages/ActivityTypeList.tsx
Normal file
230
pm_app/src/pages/ActivityTypeList.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaPlus, FaSearch, FaSync, FaTags, FaEye, FaFileExport } from 'react-icons/fa';
|
||||||
|
import { useActivityTypeList } from '../hooks/useProject';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const ActivityTypeList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const f: Record<string, unknown> = {};
|
||||||
|
if (search.trim()) f.activity_type = ['like', `%${search.trim()}%`];
|
||||||
|
return f;
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const { activityTypes, loading, totalCount, refetch } = useActivityTypeList({
|
||||||
|
filters: apiFilters,
|
||||||
|
limit_start: page * PAGE_SIZE,
|
||||||
|
limit_page_length: PAGE_SIZE,
|
||||||
|
order_by: 'name asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure pagination always triggers data reload (some environments cache identical queries).
|
||||||
|
useEffect(() => { refetch(); }, [page, apiFilters, refetch]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(activityTypes, selectionResetKey);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() => fetchAllRowsForExport({ doctype: 'Activity Type', filters: apiFilters, orderBy: 'name asc' }),
|
||||||
|
[apiFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects')}
|
||||||
|
className="text-sm text-gray-500 hover:text-teal-600 dark:text-gray-400 dark:hover:text-teal-400"
|
||||||
|
>
|
||||||
|
{t('projects.moduleTitle')}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<FaTags className="text-teal-500" /> {t('projects.activityTypeDoctype')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="p-2 text-gray-500 border border-gray-200 dark:border-gray-600 rounded-lg hover:text-teal-600"
|
||||||
|
aria-label="Refresh"
|
||||||
|
>
|
||||||
|
<FaSync size={14} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects/activity-types/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<FaPlus size={12} /> New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap gap-3 items-center">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||||
|
placeholder="Search activity type…"
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">{totalCount} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Activity Type"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={activityTypes.length}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageData={activityTypes}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="activity_types"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="w-10 px-2 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-teal-600 focus:ring-teal-500"
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
ref={el => {
|
||||||
|
if (el) el.indeterminate = someOnPageSelected;
|
||||||
|
}}
|
||||||
|
onChange={toggleAllOnPage}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Name</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Billing rate</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Costing rate</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-24"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-10 text-gray-400">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
) : activityTypes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-10 text-gray-400">No activity types found</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
activityTypes.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.name}
|
||||||
|
className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${selectedRows.has(row.name) ? 'bg-teal-50/80 dark:bg-teal-900/20' : ''}`}
|
||||||
|
onClick={() => navigate(`/projects/activity-types/${encodeURIComponent(row.name)}`)}
|
||||||
|
>
|
||||||
|
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-teal-600 focus:ring-teal-500"
|
||||||
|
checked={selectedRows.has(row.name)}
|
||||||
|
onChange={() => toggleRow(row.name)}
|
||||||
|
aria-label={`Select ${row.name}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-medium text-teal-600">{row.activity_type || row.name}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-600 dark:text-gray-300">{row.billing_rate ?? '—'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-600 dark:text-gray-300">{row.costing_rate ?? '—'}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${row.disabled ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
|
||||||
|
{row.disabled ? 'Disabled' : 'Active'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/projects/activity-types/${encodeURIComponent(row.name)}`);
|
||||||
|
}}
|
||||||
|
className="text-teal-600 hover:text-teal-800 p-1"
|
||||||
|
aria-label="View"
|
||||||
|
>
|
||||||
|
<FaEye />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{totalCount > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={(page + 1) * PAGE_SIZE >= totalCount}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityTypeList;
|
||||||
270
pm_app/src/pages/CustomerDetail.tsx
Normal file
270
pm_app/src/pages/CustomerDetail.tsx
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FaArrowLeft, FaSave, FaEdit, FaTimes,
|
||||||
|
FaCheckCircle, FaTimesCircle, FaSpinner, FaUserFriends,
|
||||||
|
FaChevronDown, FaChevronRight,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import masterService, { Customer } from '../services/masterService';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded-lg min-h-[38px] flex items-center">
|
||||||
|
{children || <span className="text-gray-400">-</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-cyan-400';
|
||||||
|
|
||||||
|
const CollapsibleSection: React.FC<{ title: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ title, children, defaultOpen = true }) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<button type="button" onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{title}</span>
|
||||||
|
{open ? <FaChevronDown className="text-gray-400 text-xs" /> : <FaChevronRight className="text-gray-400 text-xs" />}
|
||||||
|
</button>
|
||||||
|
{open && <div className="px-6 pb-5">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CUSTOMER_TYPES = ['Company', 'Individual', 'Hospital'];
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
const CustomerDetail: React.FC = () => {
|
||||||
|
const { customerName } = useParams<{ customerName: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = customerName === 'new';
|
||||||
|
|
||||||
|
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const emptyForm: Partial<Customer> = {
|
||||||
|
customer_name: '', customer_type: 'Company', customer_group: 'All Customer Groups',
|
||||||
|
territory: 'All Territories', language: 'en',
|
||||||
|
is_internal_customer: 0, default_commission_rate: 0,
|
||||||
|
so_required: 0, dn_required: 0, is_frozen: 0, disabled: 0,
|
||||||
|
};
|
||||||
|
const [form, setForm] = useState<Partial<Customer>>(emptyForm);
|
||||||
|
|
||||||
|
const syncForm = useCallback((c: Customer) => {
|
||||||
|
setForm({
|
||||||
|
customer_name: c.customer_name || '',
|
||||||
|
customer_type: c.customer_type || 'Company',
|
||||||
|
customer_group: c.customer_group || '',
|
||||||
|
territory: c.territory || '',
|
||||||
|
language: c.language || 'en',
|
||||||
|
is_internal_customer: c.is_internal_customer ?? 0,
|
||||||
|
default_commission_rate: c.default_commission_rate ?? 0,
|
||||||
|
so_required: c.so_required ?? 0,
|
||||||
|
dn_required: c.dn_required ?? 0,
|
||||||
|
is_frozen: c.is_frozen ?? 0,
|
||||||
|
disabled: c.disabled ?? 0,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew && customerName) {
|
||||||
|
setLoading(true);
|
||||||
|
masterService.getCustomer(customerName)
|
||||||
|
.then(c => { setCustomer(c); syncForm(c); })
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [customerName, isNew, syncForm]);
|
||||||
|
|
||||||
|
const set = (k: keyof Customer, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.customer_name?.trim()) { toast.error('Customer Name is required'); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
const created = await masterService.createCustomer(form);
|
||||||
|
toast.success('Customer created', { icon: <FaCheckCircle /> });
|
||||||
|
navigate(`/customers/${encodeURIComponent(created.name)}`);
|
||||||
|
} else {
|
||||||
|
const updated = await masterService.updateCustomer(customerName!, form);
|
||||||
|
toast.success('Customer updated', { icon: <FaCheckCircle /> });
|
||||||
|
setCustomer(updated); syncForm(updated); setIsEditing(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Save failed', { icon: <FaTimesCircle /> });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => { if (customer) syncForm(customer); setIsEditing(false); };
|
||||||
|
const editable = isNew || isEditing;
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center items-center min-h-[400px]"><FaSpinner className="animate-spin text-cyan-500 text-3xl" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3500} />
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500 dark:text-gray-400">
|
||||||
|
<button onClick={() => navigate('/projects')} className="hover:text-cyan-600">Project Management</button>
|
||||||
|
<span>/</span>
|
||||||
|
<button onClick={() => navigate('/customers')} className="hover:text-cyan-600">Customers</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Customer' : customerName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/customers')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"><FaArrowLeft /></button>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-cyan-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<FaUserFriends className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{isNew ? 'New Customer' : (customer?.customer_name || customerName)}
|
||||||
|
</h1>
|
||||||
|
{!isNew && customer && (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded font-medium ${customer.disabled ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
|
||||||
|
{customer.disabled ? 'Disabled' : 'Active'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isNew && !isEditing && (
|
||||||
|
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 bg-cyan-600 text-white rounded-lg hover:bg-cyan-700 text-sm">
|
||||||
|
<FaEdit /> Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-cyan-600 text-white rounded-lg hover:bg-cyan-700 disabled:opacity-50 text-sm font-medium">
|
||||||
|
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{!isNew && <button onClick={handleCancel} className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 text-sm"><FaTimes /></button>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded text-red-700 dark:text-red-300 text-sm">{error}</div>}
|
||||||
|
|
||||||
|
{/* ── Main fields ── */}
|
||||||
|
<div className="p-6 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FL required>Customer Name</FL>
|
||||||
|
{editable
|
||||||
|
? <input value={form.customer_name || ''} onChange={e => set('customer_name', e.target.value)} className={inputCls} placeholder="Enter customer name" />
|
||||||
|
: <RV>{form.customer_name}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FL>Customer Type</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={form.customer_type || 'Company'} onChange={e => set('customer_type', e.target.value)} className={inputCls}>
|
||||||
|
{CUSTOMER_TYPES.map(t => <option key={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
: <RV>{form.customer_type}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FL>Territory</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Territory" hideLabel doctype="Territory" value={form.territory || ''} onChange={v => set('territory', v)} placeholder="Select territory…" />
|
||||||
|
: <RV>{form.territory}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FL>Customer Group</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Customer Group" hideLabel doctype="Customer Group" value={form.customer_group || ''} onChange={v => set('customer_group', v)} placeholder="Select group…" />
|
||||||
|
: <RV>{form.customer_group}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FL>Language</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Language" hideLabel doctype="Language" value={form.language || ''} onChange={v => set('language', v)} placeholder="Select language…" />
|
||||||
|
: <RV>{form.language}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Settings / Defaults ── */}
|
||||||
|
<CollapsibleSection title="Settings" defaultOpen={false}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||||
|
<div>
|
||||||
|
<FL>Default Commission Rate (%)</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} max={100} step={0.01} value={form.default_commission_rate ?? 0} onChange={e => set('default_commission_rate', parseFloat(e.target.value) || 0)} className={inputCls} />
|
||||||
|
: <RV>{form.default_commission_rate ?? 0}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[
|
||||||
|
{ field: 'is_internal_customer', label: 'Is Internal Customer' },
|
||||||
|
{ field: 'so_required', label: 'Sales Order Required' },
|
||||||
|
{ field: 'dn_required', label: 'Delivery Note Required' },
|
||||||
|
{ field: 'is_frozen', label: 'Is Frozen' },
|
||||||
|
{ field: 'disabled', label: 'Disabled' },
|
||||||
|
].map(({ field, label }) => (
|
||||||
|
<div key={field} className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={field}
|
||||||
|
checked={!!form[field as keyof Customer]}
|
||||||
|
onChange={e => set(field as keyof Customer, e.target.checked ? 1 : 0)}
|
||||||
|
disabled={!editable}
|
||||||
|
className="w-4 h-4 text-cyan-600 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor={field} className="text-sm text-gray-700 dark:text-gray-300">{label}</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* ── Meta ── */}
|
||||||
|
{!isNew && customer && (
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100 dark:border-gray-700 grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Created By</span>{customer.owner}</div>
|
||||||
|
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Created</span>{customer.creation ? new Date(customer.creation).toLocaleString() : '-'}</div>
|
||||||
|
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Modified</span>{customer.modified ? new Date(customer.modified).toLocaleString() : '-'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Customer"
|
||||||
|
docname={customer?.name || customerName || ''}
|
||||||
|
creationDate={customer?.creation}
|
||||||
|
createdBy={customer?.owner}
|
||||||
|
compact={false}
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomerDetail;
|
||||||
220
pm_app/src/pages/CustomerList.tsx
Normal file
220
pm_app/src/pages/CustomerList.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaPlus, FaSearch, FaSpinner, FaUserFriends, FaArrowLeft, FaFileExport } from 'react-icons/fa';
|
||||||
|
import masterService, { Customer } from '../services/masterService';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const statusBadge = (disabled?: number) =>
|
||||||
|
disabled
|
||||||
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||||
|
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
|
||||||
|
const CustomerList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const PAGE = 20;
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const f: Record<string, any> = {};
|
||||||
|
if (search.trim()) f.customer_name = ['like', `%${search.trim()}%`];
|
||||||
|
return f;
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const load = useCallback(async (p = 0) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [{ data }, cnt] = await Promise.all([
|
||||||
|
masterService.getCustomers({ limit_start: p * PAGE, limit_page_length: PAGE, filters: apiFilters }),
|
||||||
|
masterService.getCustomerCount(apiFilters),
|
||||||
|
]);
|
||||||
|
setCustomers(data);
|
||||||
|
setTotal(cnt);
|
||||||
|
setPage(p);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to load customers');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiFilters]);
|
||||||
|
|
||||||
|
useEffect(() => { load(0); }, [load]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(customers, selectionResetKey);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() => fetchAllRowsForExport({ doctype: 'Customer', filters: apiFilters, orderBy: 'modified desc' }),
|
||||||
|
[apiFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<button onClick={() => navigate('/projects')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
|
||||||
|
<FaArrowLeft />
|
||||||
|
</button>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-cyan-600 flex items-center justify-center">
|
||||||
|
<FaUserFriends className="text-white text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Customers</h1>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{total} total</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex flex-wrap gap-2 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={(total === 0 && selectedRows.size === 0) || loading}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||||
|
<input
|
||||||
|
value={search} onChange={handleSearch}
|
||||||
|
placeholder="Search customers…"
|
||||||
|
className="pl-8 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-52 focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/customers/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-cyan-600 text-white rounded-lg hover:bg-cyan-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<FaPlus size={11} /> New Customer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Customer"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={customers.length}
|
||||||
|
totalCount={total}
|
||||||
|
pageData={customers}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="customers"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<FaSpinner className="animate-spin text-cyan-500 text-2xl" />
|
||||||
|
</div>
|
||||||
|
) : customers.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-gray-400 dark:text-gray-500">
|
||||||
|
<FaUserFriends className="mx-auto text-4xl mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">No customers found</p>
|
||||||
|
<button onClick={() => navigate('/customers/new')} className="mt-3 text-sm text-cyan-600 hover:underline">+ Create first customer</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_100px] bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
|
<div className="px-2 py-3 flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-cyan-600 focus:ring-cyan-500"
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
ref={el => {
|
||||||
|
if (el) el.indeterminate = someOnPageSelected;
|
||||||
|
}}
|
||||||
|
onChange={toggleAllOnPage}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">Customer Name</div>
|
||||||
|
<div className="px-4 py-3">Type</div>
|
||||||
|
<div className="px-4 py-3">Customer Group</div>
|
||||||
|
<div className="px-4 py-3">Territory</div>
|
||||||
|
<div className="px-4 py-3">Status</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{customers.map(c => (
|
||||||
|
<div
|
||||||
|
key={c.name}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => navigate(`/customers/${encodeURIComponent(c.name)}`)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/customers/${encodeURIComponent(c.name)}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_100px] w-full text-left hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors cursor-pointer ${selectedRows.has(c.name) ? 'bg-cyan-50/70 dark:bg-cyan-900/15' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="px-2 py-3 flex items-center justify-center" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-cyan-600 focus:ring-cyan-500"
|
||||||
|
checked={selectedRows.has(c.name)}
|
||||||
|
onChange={() => toggleRow(c.name)}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-indigo-600 dark:text-indigo-400">{c.customer_name || c.name}</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500">{c.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{c.customer_type || '-'}</div>
|
||||||
|
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{c.customer_group || '-'}</div>
|
||||||
|
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{c.territory || '-'}</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge(c.disabled)}`}>
|
||||||
|
{c.disabled ? 'Disabled' : 'Active'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Page {page + 1}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => load(page - 1)} disabled={page === 0} className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700">Prev</button>
|
||||||
|
<button onClick={() => load(page + 1)} disabled={customers.length < PAGE} className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomerList;
|
||||||
818
pm_app/src/pages/DeliveryNoteDetail.tsx
Normal file
818
pm_app/src/pages/DeliveryNoteDetail.tsx
Normal file
@ -0,0 +1,818 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||||
|
FaSpinner, FaTruck, FaPaperPlane, FaFileInvoiceDollar,
|
||||||
|
FaChevronDown, FaChevronRight, FaPencilAlt,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import deliveryNoteService, { DeliveryNote, DeliveryNoteItem } from '../services/deliveryNoteService';
|
||||||
|
import salesOrderService from '../services/salesOrderService';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||||
|
import {
|
||||||
|
DEFAULT_COMPANY, DEFAULT_CURRENCY, DEFAULT_SALES_TAXES_TEMPLATE,
|
||||||
|
taxRatePercent, displayTxnCurrency,
|
||||||
|
} from '../constants/orgDefaults';
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded min-h-[34px] flex items-center">
|
||||||
|
{children || <span className="text-gray-400">-</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||||
|
const inlineTxt = 'w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||||
|
const inlineNum = 'w-full px-2 py-1 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||||
|
const editorInput = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||||
|
const editorNum = 'w-full px-3 py-2 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-teal-400';
|
||||||
|
const roField = 'w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-800/60 text-gray-600 dark:text-gray-400 rounded min-h-[34px] flex items-center';
|
||||||
|
|
||||||
|
// ── Collapsible group ─────────────────────────────────────────────────────────
|
||||||
|
const RGroup: React.FC<{ label: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ label, children, defaultOpen = false }) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden mt-3">
|
||||||
|
<button type="button" onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-gray-800/80 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors text-left">
|
||||||
|
{open ? <FaChevronDown size={9} /> : <FaChevronRight size={9} />}{label}
|
||||||
|
</button>
|
||||||
|
{open && <div className="p-3 grid grid-cols-1 sm:grid-cols-2 gap-3 bg-white dark:bg-gray-800">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Create Dropdown ───────────────────────────────────────────────────────────
|
||||||
|
const CreateDropdown: React.FC<{ items: { label: string; icon: React.ReactNode; onClick: () => void }[] }> = ({ items }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', h);
|
||||||
|
return () => document.removeEventListener('mousedown', h);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button onClick={() => setOpen(o => !o)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium shadow-sm">
|
||||||
|
Create <FaChevronDown size={10} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-1 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 py-1.5">
|
||||||
|
<div className="px-3 py-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-gray-700 mb-1">Create from this note</div>
|
||||||
|
{items.map(({ label, icon, onClick }) => (
|
||||||
|
<button key={label} onClick={() => { onClick(); setOpen(false); }}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-teal-50 dark:hover:bg-teal-900/20 hover:text-teal-700 transition-colors text-left">
|
||||||
|
<span className="text-gray-400">{icon}</span>{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── DN Item Row Editor ────────────────────────────────────────────────────────
|
||||||
|
interface TaxRow { charge_type?: string; account_head?: string; description?: string; included_in_print_rate?: number; rate?: number; tax_amount?: number; total?: number; cost_center?: string; account_currency?: string; idx?: number; [k: string]: any; }
|
||||||
|
|
||||||
|
const DNItemRowEditor: React.FC<{
|
||||||
|
item: Partial<DeliveryNoteItem>;
|
||||||
|
rowNo: number;
|
||||||
|
currency: string;
|
||||||
|
onChange: (k: string, v: any) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onInsertBelow: () => void;
|
||||||
|
}> = ({ item, rowNo, currency, onChange, onClose, onDelete, onInsertBelow }) => {
|
||||||
|
const set = (k: string, v: any) => onChange(k, v);
|
||||||
|
const cur = currency || DEFAULT_CURRENCY;
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="p-0">
|
||||||
|
<div className="bg-blue-50/60 dark:bg-blue-900/10 border-b border-blue-200 dark:border-blue-800 px-4 py-3">
|
||||||
|
{/* editor header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-teal-700 dark:text-teal-300 uppercase tracking-wider">Editing Row #{rowNo}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={onInsertBelow} className="px-2 py-1 text-[11px] bg-teal-600 text-white rounded hover:bg-teal-700">Insert Below</button>
|
||||||
|
<button onClick={onDelete} className="px-2 py-1 text-[11px] bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||||
|
<button onClick={onClose} className="px-2 py-1 text-[11px] bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300">ESC</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* top fields */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div><FL required>Item Code</FL><LinkField label="Item Code" hideLabel doctype="Item" value={item.item_code || ''} onChange={v => set('item_code', v)} /></div>
|
||||||
|
<div><FL>Item Name</FL><input value={item.item_name || ''} onChange={e => set('item_name', e.target.value)} className={editorInput} /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RGroup label="Description">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FL>Description</FL>
|
||||||
|
<textarea value={item.description || ''} onChange={e => set('description', e.target.value)} rows={2} className={editorInput + ' resize-none'} />
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Quantity and Warehouse" defaultOpen>
|
||||||
|
<div><FL required>Quantity</FL><input type="number" min={0} step="1" value={item.qty ?? 1} onChange={e => set('qty', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||||
|
<div><FL required>UOM</FL><LinkField label="UOM" hideLabel doctype="UOM" value={item.uom || ''} onChange={v => set('uom', v)} /></div>
|
||||||
|
<div><FL>Stock UOM</FL><LinkField label="Stock UOM" hideLabel doctype="UOM" value={item.stock_uom || ''} onChange={v => set('stock_uom', v)} /></div>
|
||||||
|
<div><FL>UOM Conversion Factor</FL><input type="number" min={0} step="0.0001" value={item.conversion_factor ?? 1} onChange={e => set('conversion_factor', parseFloat(e.target.value) || 1)} className={editorNum} /></div>
|
||||||
|
<div><FL>Stock Qty (auto)</FL><div className={roField}>{((item.qty || 1) * (item.conversion_factor || 1)).toFixed(3)}</div></div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label={`Discount and Margin (${cur})`} defaultOpen>
|
||||||
|
<div><FL>Price List Rate ({cur})</FL><input type="number" min={0} step="0.01" value={item.price_list_rate ?? 0} onChange={e => set('price_list_rate', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||||
|
<div><FL>Discount %</FL><input type="number" min={0} max={100} step="0.01" value={item.discount_percentage ?? 0} onChange={e => set('discount_percentage', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||||
|
<div><FL>Discount Amount ({cur})</FL><input type="number" min={0} step="0.01" value={item.discount_amount ?? 0} onChange={e => set('discount_amount', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||||
|
<div><FL>Rate ({cur})</FL><input type="number" min={0} step="0.01" value={item.rate ?? 0} onChange={e => set('rate', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||||
|
<div><FL>Amount ({cur}) (auto)</FL><div className={roField}>{((item.qty || 0) * (item.rate || 0)).toFixed(2)}</div></div>
|
||||||
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<input type="checkbox" id={`fi-${rowNo}`} checked={!!item.is_free_item} onChange={e => set('is_free_item', e.target.checked ? 1 : 0)} className="w-4 h-4 text-teal-600 rounded" />
|
||||||
|
<label htmlFor={`fi-${rowNo}`} className="text-sm text-gray-700 dark:text-gray-300">Is Free Item</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<input type="checkbox" id={`gc-${rowNo}`} checked={!!item.grant_commission} onChange={e => set('grant_commission', e.target.checked ? 1 : 0)} className="w-4 h-4 text-teal-600 rounded" />
|
||||||
|
<label htmlFor={`gc-${rowNo}`} className="text-sm text-gray-700 dark:text-gray-300">Grant Commission</label>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Warehouse and Reference">
|
||||||
|
<div><FL>Warehouse</FL><LinkField label="Warehouse" hideLabel doctype="Warehouse" value={(item as any).warehouse || ''} onChange={v => set('warehouse', v)} /></div>
|
||||||
|
<div><FL>Against Sales Order</FL><div className={roField}>{(item as any).against_sales_order || '-'}</div></div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Available Quantity">
|
||||||
|
<div><FL>Actual Qty (Warehouse)</FL><div className={roField}>{(item as any).actual_qty ?? 0}</div></div>
|
||||||
|
<div><FL>Company Total Stock</FL><div className={roField}>{(item as any).company_total_stock ?? 0}</div></div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Item Weight Details">
|
||||||
|
<div><FL>Weight Per Unit</FL><input type="number" min={0} step="0.001" value={(item as any).weight_per_unit ?? 0} onChange={e => set('weight_per_unit', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||||
|
<div><FL>Total Weight (auto)</FL><div className={roField}>{(((item as any).weight_per_unit || 0) * (item.qty || 0)).toFixed(3)}</div></div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Accounting Details">
|
||||||
|
<div className="sm:col-span-2"><FL>Expense Account</FL><LinkField label="Expense Account" hideLabel doctype="Account" value={(item as any).expense_account || ''} onChange={v => set('expense_account', v)} /></div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Accounting Dimensions">
|
||||||
|
<div><FL>Cost Center</FL><LinkField label="Cost Center" hideLabel doctype="Cost Center" value={(item as any).cost_center || ''} onChange={v => set('cost_center', v)} /></div>
|
||||||
|
<div><FL>Project</FL><LinkField label="Project" hideLabel doctype="Project" value={(item as any).project || ''} onChange={v => set('project', v)} /></div>
|
||||||
|
</RGroup>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tax Row Editor ────────────────────────────────────────────────────────────
|
||||||
|
const DNTaxRowEditor: React.FC<{
|
||||||
|
tax: TaxRow; rowNo: number;
|
||||||
|
onChange: (k: string, v: any) => void;
|
||||||
|
onClose: () => void; onDelete: () => void;
|
||||||
|
}> = ({ tax, rowNo, onChange, onClose, onDelete }) => {
|
||||||
|
const set = (k: string, v: any) => onChange(k, v);
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="p-0">
|
||||||
|
<div className="bg-blue-50/60 dark:bg-blue-900/10 border-b border-blue-200 dark:border-blue-800 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-teal-700 dark:text-teal-300 uppercase tracking-wider">Editing Tax Row #{rowNo}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={onDelete} className="px-2 py-1 text-[11px] bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||||
|
<button onClick={onClose} className="px-2 py-1 text-[11px] bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300">ESC</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div><FL required>Type</FL>
|
||||||
|
<select value={tax.charge_type || ''} onChange={e => set('charge_type', e.target.value)} className={editorInput}>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
<option value="Actual">Actual</option>
|
||||||
|
<option value="On Net Total">On Net Total</option>
|
||||||
|
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||||
|
<option value="On Previous Row Total">On Previous Row Total</option>
|
||||||
|
<option value="On Item Quantity">On Item Quantity</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><FL>Description</FL><input value={tax.description || ''} onChange={e => set('description', e.target.value)} className={editorInput} /></div>
|
||||||
|
<div><FL required>Account Head</FL><LinkField label="Account Head" hideLabel doctype="Account" value={tax.account_head || ''} onChange={v => set('account_head', v)} /></div>
|
||||||
|
<div className="flex items-center gap-2 pt-5">
|
||||||
|
<input type="checkbox" id={`incl-${rowNo}`} checked={!!tax.included_in_print_rate} onChange={e => set('included_in_print_rate', e.target.checked ? 1 : 0)} className="w-4 h-4 text-teal-600 rounded" />
|
||||||
|
<label htmlFor={`incl-${rowNo}`} className="text-sm text-gray-700 dark:text-gray-300">Included in Basic Rate</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RGroup label="Accounting Dimensions">
|
||||||
|
<div><FL>Cost Center</FL><LinkField label="Cost Center" hideLabel doctype="Cost Center" value={tax.cost_center || ''} onChange={v => set('cost_center', v)} /></div>
|
||||||
|
<div><FL>Account Currency</FL><input value={tax.account_currency || ''} onChange={e => set('account_currency', e.target.value)} className={editorInput} /></div>
|
||||||
|
</RGroup>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Empty helpers ─────────────────────────────────────────────────────────────
|
||||||
|
const emptyItem = (): Partial<DeliveryNoteItem> => ({ item_code: '', item_name: '', qty: 1, rate: 0, amount: 0, uom: '', conversion_factor: 1 });
|
||||||
|
const emptyTax = (): TaxRow => ({ charge_type: 'On Net Total', account_head: '', rate: 15 });
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
const DeliveryNoteDetail: React.FC = () => {
|
||||||
|
const { dnName } = useParams<{ dnName: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = dnName === 'new';
|
||||||
|
|
||||||
|
const contextSO = searchParams.get('so') || '';
|
||||||
|
const contextCustomer = searchParams.get('customer') || '';
|
||||||
|
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||||
|
const contextProject = searchParams.get('project') || '';
|
||||||
|
|
||||||
|
const [doc, setDoc] = useState<DeliveryNote | null>(null);
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||||
|
const [expandedTax, setExpandedTax] = useState<number | null>(null);
|
||||||
|
const [taxes, setTaxes] = useState<TaxRow[]>([]);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Partial<DeliveryNote>>({
|
||||||
|
customer: contextCustomer, company: contextCompany, project: contextProject,
|
||||||
|
posting_date: today, currency: DEFAULT_CURRENCY,
|
||||||
|
taxes_and_charges: DEFAULT_SALES_TAXES_TEMPLATE,
|
||||||
|
items: [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const syncForm = useCallback((d: DeliveryNote) => {
|
||||||
|
setForm({
|
||||||
|
customer: d.customer || '', company: d.company || DEFAULT_COMPANY, project: d.project || '',
|
||||||
|
posting_date: d.posting_date || today,
|
||||||
|
currency: d.currency === 'INR' ? DEFAULT_CURRENCY : (d.currency || DEFAULT_CURRENCY),
|
||||||
|
cost_center: d.cost_center || '', items: d.items || [],
|
||||||
|
selling_price_list: (d as any).selling_price_list || '',
|
||||||
|
price_list_currency: (d as any).price_list_currency || '',
|
||||||
|
conversion_rate: (d as any).conversion_rate || 1,
|
||||||
|
plc_conversion_rate: (d as any).plc_conversion_rate || 1,
|
||||||
|
tax_category: (d as any).tax_category || '',
|
||||||
|
taxes_and_charges: (d as any).taxes_and_charges || '',
|
||||||
|
} as any);
|
||||||
|
setTaxes((d as any).taxes || []);
|
||||||
|
}, [today]);
|
||||||
|
|
||||||
|
// Auto-fetch company currency when company changes on new form
|
||||||
|
useEffect(() => {
|
||||||
|
const company = (form as any).company;
|
||||||
|
if (!isNew || !company) return;
|
||||||
|
fetch(`/api/resource/Company/${encodeURIComponent(company)}`, { credentials: 'include' })
|
||||||
|
.then(r => r.json()).then(b => {
|
||||||
|
if (b.data?.default_currency) {
|
||||||
|
const cur = displayTxnCurrency(b.data.default_currency);
|
||||||
|
setForm(f => ({ ...f, currency: cur,
|
||||||
|
selling_price_list: (f as any).selling_price_list || 'Standard Selling',
|
||||||
|
price_list_currency: (f as any).price_list_currency || cur,
|
||||||
|
} as any));
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [(form as any).company, isNew]);
|
||||||
|
|
||||||
|
// Pre-fill from SO
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew || !contextSO) return;
|
||||||
|
salesOrderService.getSalesOrder(contextSO).then(so => {
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
customer: so.customer || f.customer,
|
||||||
|
company: so.company || f.company,
|
||||||
|
project: so.project || (f as any).project,
|
||||||
|
cost_center: so.cost_center || (f as any).cost_center,
|
||||||
|
currency: so.currency || (f as any).currency,
|
||||||
|
selling_price_list: (so as any).selling_price_list || 'Standard Selling',
|
||||||
|
price_list_currency: (so as any).price_list_currency || so.currency,
|
||||||
|
conversion_rate: (so as any).conversion_rate || 1,
|
||||||
|
plc_conversion_rate: (so as any).plc_conversion_rate || 1,
|
||||||
|
taxes_and_charges: (so as any).taxes_and_charges || '',
|
||||||
|
tax_category: (so as any).tax_category || '',
|
||||||
|
items: (so.items || []).map(it => ({
|
||||||
|
item_code: it.item_code, item_name: it.item_name,
|
||||||
|
description: (it as any).description || it.item_name || it.item_code,
|
||||||
|
qty: it.qty, uom: it.uom, stock_uom: it.stock_uom,
|
||||||
|
rate: it.rate, amount: it.amount,
|
||||||
|
against_sales_order: contextSO,
|
||||||
|
so_detail: (it as any).name || undefined,
|
||||||
|
conversion_factor: it.conversion_factor ?? 1,
|
||||||
|
warehouse: (it as any).warehouse || undefined,
|
||||||
|
expense_account: (it as any).expense_account || undefined,
|
||||||
|
cost_center: (it as any).cost_center || so.cost_center || undefined,
|
||||||
|
project: (it as any).project || so.project || undefined,
|
||||||
|
})),
|
||||||
|
} as any));
|
||||||
|
// Also load the taxes from SO's tax template
|
||||||
|
if ((so as any).taxes_and_charges) {
|
||||||
|
loadTaxTemplate((so as any).taxes_and_charges);
|
||||||
|
} else if ((so as any).taxes?.length) {
|
||||||
|
setTaxes((so as any).taxes.map((tx: any) => ({
|
||||||
|
charge_type: tx.charge_type, account_head: tx.account_head,
|
||||||
|
description: tx.description, rate: tx.rate ?? tx.tax_rate ?? 0,
|
||||||
|
cost_center: tx.cost_center, account_currency: tx.account_currency,
|
||||||
|
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [isNew, contextSO]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return;
|
||||||
|
setLoading(true);
|
||||||
|
deliveryNoteService.getDeliveryNote(dnName!)
|
||||||
|
.then(d => { setDoc(d); syncForm(d); })
|
||||||
|
.catch(e => toast.error(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [dnName, isNew, syncForm]);
|
||||||
|
|
||||||
|
const set = (k: keyof DeliveryNote, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const updateItem = (idx: number, k: string, v: any) =>
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
const updated: any = { ...items[idx], [k]: v };
|
||||||
|
if (k === 'qty' || k === 'rate') {
|
||||||
|
const qty = parseFloat(String(k === 'qty' ? v : updated.qty)) || 0;
|
||||||
|
const rate = parseFloat(String(k === 'rate' ? v : updated.rate)) || 0;
|
||||||
|
updated.amount = parseFloat((qty * rate).toFixed(4));
|
||||||
|
}
|
||||||
|
items[idx] = updated;
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleItemCode = async (idx: number, code: string) => {
|
||||||
|
updateItem(idx, 'item_code', code);
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/resource/Item/${encodeURIComponent(code)}`, { credentials: 'include' });
|
||||||
|
const body = await r.json();
|
||||||
|
const d = body.data;
|
||||||
|
if (!d) return;
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
items[idx] = { ...items[idx], item_code: code, item_name: d.item_name || code, stock_uom: d.stock_uom || '', uom: d.sales_uom || d.stock_uom || '', description: d.description || '' };
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = (after?: number) => {
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
const pos = after !== undefined ? after + 1 : items.length;
|
||||||
|
items.splice(pos, 0, emptyItem());
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeItem = (idx: number) => { setForm(f => { const items = [...(f.items || [])]; items.splice(idx, 1); return { ...f, items }; }); setExpandedItem(null); };
|
||||||
|
|
||||||
|
const updateTax = (idx: number, k: string, v: any) => setTaxes(prev => { const t = [...prev]; t[idx] = { ...t[idx], [k]: v }; return t; });
|
||||||
|
const addTax = () => setTaxes(prev => [...prev, emptyTax()]);
|
||||||
|
const removeTax = (idx: number) => { setTaxes(prev => { const t = [...prev]; t.splice(idx, 1); return t; }); setExpandedTax(null); };
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
const netTotal = (form.items || []).reduce((s, it) => s + (it.amount || 0), 0);
|
||||||
|
const taxTotal = taxes.reduce((s, tx) => {
|
||||||
|
const pct = taxRatePercent(tx);
|
||||||
|
if (tx.charge_type === 'On Net Total') return s + netTotal * (pct / 100);
|
||||||
|
if (tx.charge_type === 'Actual') return s + (tx.tax_amount || 0);
|
||||||
|
return s + netTotal * (pct / 100);
|
||||||
|
}, 0);
|
||||||
|
const grandTotal = netTotal + taxTotal;
|
||||||
|
|
||||||
|
const loadTaxTemplate = async (templateName: string) => {
|
||||||
|
if (!templateName) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/resource/Sales Taxes and Charges Template/${encodeURIComponent(templateName)}`, { credentials: 'include' });
|
||||||
|
const body = await r.json();
|
||||||
|
const tmpl = body.data;
|
||||||
|
if (tmpl?.taxes?.length) {
|
||||||
|
setTaxes(tmpl.taxes.map((tx: any) => ({
|
||||||
|
charge_type: tx.charge_type, account_head: tx.account_head,
|
||||||
|
description: tx.description, rate: tx.rate,
|
||||||
|
cost_center: tx.cost_center, account_currency: tx.account_currency,
|
||||||
|
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew || contextSO) return;
|
||||||
|
void loadTaxTemplate(DEFAULT_SALES_TAXES_TEMPLATE);
|
||||||
|
}, [isNew, contextSO]);
|
||||||
|
|
||||||
|
const buildPayload = (resolvedItems?: any[]): Partial<DeliveryNote> => ({
|
||||||
|
customer: form.customer, company: (form as any).company || undefined,
|
||||||
|
project: (form as any).project || undefined, cost_center: (form as any).cost_center || undefined,
|
||||||
|
posting_date: form.posting_date, currency: form.currency || undefined,
|
||||||
|
selling_price_list: (form as any).selling_price_list || 'Standard Selling',
|
||||||
|
price_list_currency: (form as any).price_list_currency || form.currency || undefined,
|
||||||
|
conversion_rate: (form as any).conversion_rate || 1,
|
||||||
|
plc_conversion_rate: (form as any).plc_conversion_rate || 1,
|
||||||
|
tax_category: (form as any).tax_category || undefined,
|
||||||
|
taxes_and_charges: (form as any).taxes_and_charges || undefined,
|
||||||
|
items: (resolvedItems || form.items || []).filter((it: any) => it.item_code).map((it: any, i: number) => ({
|
||||||
|
item_code: it.item_code, item_name: it.item_name || it.item_code,
|
||||||
|
description: it.description || undefined,
|
||||||
|
qty: it.qty ?? 1, uom: it.uom || undefined, stock_uom: it.stock_uom || undefined,
|
||||||
|
conversion_factor: it.conversion_factor ?? 1,
|
||||||
|
price_list_rate: it.price_list_rate ?? 0,
|
||||||
|
discount_percentage: it.discount_percentage ?? 0,
|
||||||
|
discount_amount: it.discount_amount ?? 0,
|
||||||
|
rate: it.rate ?? 0, amount: it.amount ?? 0,
|
||||||
|
is_free_item: it.is_free_item ?? 0, grant_commission: it.grant_commission ?? 0,
|
||||||
|
warehouse: it.warehouse || undefined, expense_account: it.expense_account || undefined,
|
||||||
|
cost_center: it.cost_center || undefined, project: it.project || (form as any).project || undefined,
|
||||||
|
against_sales_order: it.against_sales_order || undefined,
|
||||||
|
so_detail: it.so_detail || undefined,
|
||||||
|
weight_per_unit: it.weight_per_unit ?? 0, total_weight: it.total_weight ?? 0,
|
||||||
|
idx: i + 1,
|
||||||
|
})),
|
||||||
|
taxes: taxes.filter(tx => tx.account_head).map((tx, i) => ({
|
||||||
|
charge_type: tx.charge_type || 'On Net Total',
|
||||||
|
account_head: tx.account_head, description: tx.description || tx.account_head,
|
||||||
|
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||||
|
rate: tx.rate ?? 0, cost_center: tx.cost_center || undefined,
|
||||||
|
account_currency: tx.account_currency || undefined, idx: i + 1,
|
||||||
|
})),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
/** Fetch SO item row names to fill so_detail on items that have against_sales_order but missing so_detail */
|
||||||
|
const resolveSoDetails = async (items: any[]): Promise<any[]> => {
|
||||||
|
const needsResolve = items.filter(it => it.against_sales_order && !it.so_detail);
|
||||||
|
if (!needsResolve.length) return items;
|
||||||
|
const soName = needsResolve[0].against_sales_order;
|
||||||
|
try {
|
||||||
|
const r = await fetch(
|
||||||
|
`/api/resource/Sales%20Order%20Item?filters=${encodeURIComponent(JSON.stringify([["parent","=",soName]]))}&fields=${encodeURIComponent(JSON.stringify(["name","item_code","idx"]))}&limit=100`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
const j = await r.json();
|
||||||
|
const soRows: any[] = (j.data || []).sort((a: any, b: any) => (a.idx || 0) - (b.idx || 0));
|
||||||
|
const used = new Set<string>();
|
||||||
|
return items.map(it => {
|
||||||
|
if (!it.against_sales_order || it.so_detail) return it;
|
||||||
|
const match = soRows.find((s: any) => s.item_code === it.item_code && !used.has(s.name));
|
||||||
|
if (match) { used.add(match.name); return { ...it, so_detail: match.name }; }
|
||||||
|
return it;
|
||||||
|
});
|
||||||
|
} catch { return items; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.customer) { toast.error('Customer is required'); return; }
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const resolvedItems = await resolveSoDetails([...(form.items || [])]);
|
||||||
|
if (isNew) {
|
||||||
|
const created = await deliveryNoteService.createDeliveryNote(buildPayload(resolvedItems));
|
||||||
|
toast.success('Delivery Note created');
|
||||||
|
setIsEditing(false);
|
||||||
|
navigate(`/delivery-notes/${created.name}`);
|
||||||
|
} else {
|
||||||
|
const updated = await deliveryNoteService.updateDeliveryNote(dnName!, buildPayload(resolvedItems));
|
||||||
|
setDoc(updated); syncForm(updated);
|
||||||
|
toast.success('Delivery Note saved');
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) { toast.error(formatFrappeApiError(e) || 'Error saving'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!dnName || isNew) return;
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const updated = await deliveryNoteService.submitDeliveryNote(dnName);
|
||||||
|
setDoc(updated); syncForm(updated);
|
||||||
|
toast.success('Delivery Note submitted');
|
||||||
|
} catch (e: unknown) { toast.error(formatFrappeApiError(e) || 'Error submitting'); }
|
||||||
|
finally { setSubmitting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSI = () => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('dn', dnName!);
|
||||||
|
if (form.customer) p.set('customer', form.customer);
|
||||||
|
if ((form as any).company) p.set('company', String((form as any).company));
|
||||||
|
if ((form as any).project) p.set('project', String((form as any).project));
|
||||||
|
navigate(`/invoices/new?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editable = isNew || isEditing;
|
||||||
|
const isSubmitted = !isNew && doc?.docstatus === 1;
|
||||||
|
|
||||||
|
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-teal-500 text-3xl" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3500} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500">
|
||||||
|
<button onClick={() => navigate('/projects')} className="hover:text-indigo-600">Project Management</button>
|
||||||
|
<span>/</span>
|
||||||
|
<button onClick={() => navigate('/delivery-notes')} className="hover:text-indigo-600">Delivery Notes</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Delivery Note' : dnName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/delivery-notes')} className="text-gray-400 hover:text-gray-700"><FaArrowLeft /></button>
|
||||||
|
<FaTruck className="text-teal-500" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{isNew ? 'New Delivery Note' : (form.customer || dnName)}
|
||||||
|
</h1>
|
||||||
|
{!isNew && <span className="text-sm text-gray-400 font-normal">{dnName}</span>}
|
||||||
|
{!isNew && (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${(() => { const s = doc?.status || ''; if (doc?.docstatus === 2 || s === 'Cancelled') return 'bg-red-100 text-red-700'; if (!doc || doc.docstatus === 0) return 'bg-yellow-100 text-yellow-800'; if (s === 'Completed') return 'bg-green-100 text-green-800'; if (s === 'To Bill') return 'bg-blue-100 text-blue-800'; if (s === 'Return Issued') return 'bg-orange-100 text-orange-800'; if (s === 'Closed') return 'bg-gray-100 text-gray-700'; return 'bg-green-100 text-green-800'; })()}`}>
|
||||||
|
{doc?.docstatus === 2 ? 'Cancelled' : doc?.docstatus === 0 ? 'Draft' : (doc?.status || 'Submitted')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{isSubmitted && (
|
||||||
|
<CreateDropdown items={[
|
||||||
|
{ label: 'Sales Invoice', icon: <FaFileInvoiceDollar size={13} />, onClick: createSI },
|
||||||
|
]} />
|
||||||
|
)}
|
||||||
|
{!isNew && !isEditing && doc?.docstatus === 0 && (
|
||||||
|
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
|
||||||
|
{submitting ? <FaSpinner className="animate-spin" /> : <FaPaperPlane size={12} />} Submit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isNew && !isEditing && !isSubmitted && (
|
||||||
|
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-teal-500 text-teal-600 rounded-lg hover:bg-teal-50 text-sm"><FaEdit /> Edit</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 text-sm font-medium">
|
||||||
|
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{!isNew && <button onClick={() => { if (doc) syncForm(doc); setIsEditing(false); }} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-600 text-sm"><FaTimes /></button>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Fields */}
|
||||||
|
<div className="px-6 pt-5 pb-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||||
|
<div><FL required>Customer</FL>
|
||||||
|
{editable ? <LinkField label="Customer" hideLabel doctype="Customer" value={form.customer || ''} onChange={v => set('customer', v)} placeholder="Select customer…" /> : <RV>{form.customer}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL required>Posting Date</FL>
|
||||||
|
{editable ? <input type="date" value={form.posting_date || ''} onChange={e => set('posting_date', e.target.value)} className={inputCls} /> : <RV>{form.posting_date}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Company</FL>
|
||||||
|
{editable ? <LinkField label="Company" hideLabel doctype="Company" value={(form as any).company || ''} onChange={v => set('company' as any, v)} placeholder="Select company…" /> : <RV>{(form as any).company}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Project</FL>
|
||||||
|
{editable ? <LinkField label="Project" hideLabel doctype="Project" value={(form as any).project || ''} onChange={v => set('project' as any, v)} placeholder="Select project…" /> : <RV>{(form as any).project}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Currency</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={form.currency || DEFAULT_CURRENCY} onChange={e => set('currency', e.target.value)} className={inputCls}>
|
||||||
|
<option value="SAR">SAR</option><option value="USD">USD</option><option value="EUR">EUR</option>
|
||||||
|
</select>
|
||||||
|
: <RV>{form.currency}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Cost Center</FL>
|
||||||
|
{editable ? <LinkField label="Cost Center" hideLabel doctype="Cost Center" value={(form as any).cost_center || ''} onChange={v => set('cost_center' as any, v)} /> : <RV>{(form as any).cost_center}</RV>}
|
||||||
|
</div>
|
||||||
|
{contextSO && <div><FL>Against Sales Order</FL><RV>{contextSO}</RV></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Section */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Items</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
<div className="overflow-x-auto -mx-2 mt-3">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Item Code <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">UOM <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Qty <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Rate ({form.currency || DEFAULT_CURRENCY})</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount ({form.currency || DEFAULT_CURRENCY})</th>
|
||||||
|
{editable && <th className="w-16 py-2 px-2" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(form.items || []).map((it: any, idx) => (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedItem === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
||||||
|
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||||
|
<td className="py-1.5 px-2 min-w-[180px]">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Item" hideLabel doctype="Item" value={it.item_code || ''} onChange={v => handleItemCode(idx, v)} placeholder="Item Code" />
|
||||||
|
: <span className="font-medium text-gray-800 dark:text-gray-200">{it.item_code || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-28">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="UOM" hideLabel doctype="UOM" value={it.uom || ''} onChange={v => updateItem(idx, 'uom', v)} placeholder="UOM" />
|
||||||
|
: <span className="text-gray-500 text-sm">{it.uom || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-24">
|
||||||
|
{editable ? <input type="number" min={0} step="1" value={it.qty ?? 1} onChange={e => updateItem(idx, 'qty', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-sm pr-1">{it.qty ?? 0}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-28">
|
||||||
|
{editable ? <input type="number" min={0} step="0.01" value={it.rate ?? 0} onChange={e => updateItem(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-sm pr-1">{(it.rate ?? 0).toFixed(2)}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{((it.qty || 0) * (it.rate || 0)).toFixed(2)}</td>
|
||||||
|
{editable && (
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setExpandedItem(expandedItem === idx ? null : idx)}
|
||||||
|
className={`p-1.5 rounded text-xs ${expandedItem === idx ? 'bg-teal-600 text-white' : 'text-teal-600 hover:bg-teal-50'}`} title="More fields">
|
||||||
|
<FaPencilAlt size={11} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => removeItem(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{editable && expandedItem === idx && (
|
||||||
|
<DNItemRowEditor
|
||||||
|
item={it} rowNo={idx + 1} currency={form.currency || DEFAULT_CURRENCY}
|
||||||
|
onChange={(k, v) => { if (k === 'item_code') handleItemCode(idx, v as string); else updateItem(idx, k, v); }}
|
||||||
|
onClose={() => setExpandedItem(null)}
|
||||||
|
onDelete={() => removeItem(idx)}
|
||||||
|
onInsertBelow={() => addItem(idx)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{editable && (
|
||||||
|
<tr><td colSpan={7} className="py-2 px-3">
|
||||||
|
<button onClick={() => addItem()} className="flex items-center gap-1.5 text-teal-600 hover:text-teal-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Taxes Section */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Taxes and Charges</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pt-4 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||||
|
<div><FL>Tax Category</FL>
|
||||||
|
{editable ? <LinkField label="Tax Category" hideLabel doctype="Tax Category" value={(form as any).tax_category || ''} onChange={v => set('tax_category' as any, v)} placeholder="Select tax category…" /> : <RV>{(form as any).tax_category}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Sales Taxes and Charges Template</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Sales Taxes and Charges Template" hideLabel doctype="Sales Taxes and Charges Template" value={(form as any).taxes_and_charges || ''} onChange={v => { set('taxes_and_charges' as any, v); loadTaxTemplate(v); }} placeholder="Select template…" />
|
||||||
|
: <RV>{(form as any).taxes_and_charges}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-4 mt-3">
|
||||||
|
<div className="overflow-x-auto -mx-2">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-36">Type <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Account Head <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Tax Rate</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Total</th>
|
||||||
|
{editable && <th className="w-16 py-2 px-2" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{taxes.map((tx, idx) => {
|
||||||
|
const txAmt = tx.charge_type === 'On Net Total' ? netTotal * ((tx.rate || 0) / 100) : (tx.tax_amount || 0);
|
||||||
|
const txTotal = netTotal + taxes.slice(0, idx + 1).reduce((s, t) => {
|
||||||
|
return s + (t.charge_type === 'On Net Total' ? netTotal * ((t.rate || 0) / 100) : (t.tax_amount || 0));
|
||||||
|
}, 0);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedTax === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
||||||
|
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||||
|
<td className="py-1.5 px-2 w-36">
|
||||||
|
{editable
|
||||||
|
? <select value={tx.charge_type || ''} onChange={e => updateTax(idx, 'charge_type', e.target.value)} className={inlineTxt}>
|
||||||
|
<option value="">Type…</option>
|
||||||
|
<option value="Actual">Actual</option>
|
||||||
|
<option value="On Net Total">On Net Total</option>
|
||||||
|
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||||
|
</select>
|
||||||
|
: <span className="text-sm text-gray-700">{tx.charge_type || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 min-w-[180px]">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Account Head" hideLabel doctype="Account" value={tx.account_head || ''} onChange={v => updateTax(idx, 'account_head', v)} placeholder="Account…" />
|
||||||
|
: <span className="text-sm text-gray-700">{tx.account_head || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-24">
|
||||||
|
{editable ? <input type="number" min={0} step="0.01" value={tx.rate ?? 0} onChange={e => updateTax(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-sm pr-1">{tx.rate ?? 0}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-3 text-right text-sm text-gray-700 dark:text-gray-300">{txAmt.toFixed(2)}</td>
|
||||||
|
<td className="py-1.5 px-3 text-right text-sm font-semibold text-gray-900 dark:text-white">{txTotal.toFixed(2)}</td>
|
||||||
|
{editable && (
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setExpandedTax(expandedTax === idx ? null : idx)}
|
||||||
|
className={`p-1.5 rounded text-xs ${expandedTax === idx ? 'bg-teal-600 text-white' : 'text-teal-600 hover:bg-teal-50'}`}><FaPencilAlt size={11} /></button>
|
||||||
|
<button onClick={() => removeTax(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{editable && expandedTax === idx && (
|
||||||
|
<DNTaxRowEditor tax={tx} rowNo={idx + 1}
|
||||||
|
onChange={(k, v) => updateTax(idx, k, v)}
|
||||||
|
onClose={() => setExpandedTax(null)} onDelete={() => removeTax(idx)} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{editable && (
|
||||||
|
<tr><td colSpan={7} className="py-2 px-3">
|
||||||
|
<button onClick={addTax} className="flex items-center gap-1.5 text-teal-600 hover:text-teal-700 text-sm font-medium"><FaPlus size={10} /> Add Tax Row</button>
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="w-full max-w-xs space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||||
|
<span>Net Total ({displayTxnCurrency(form.currency)})</span>
|
||||||
|
<span className="font-medium">{netTotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||||
|
<span>Total Taxes and Charges ({displayTxnCurrency(form.currency)})</span>
|
||||||
|
<span className="font-medium">{(doc?.total_taxes_and_charges ?? taxTotal).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between font-bold text-gray-900 dark:text-white border-t border-gray-200 dark:border-gray-700 pt-2 text-base">
|
||||||
|
<span>Grand Total ({displayTxnCurrency(form.currency)})</span>
|
||||||
|
<span>{grandTotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
{!isNew && doc && (
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4 grid grid-cols-3 gap-4 text-sm bg-gray-50 dark:bg-gray-900/20">
|
||||||
|
<div><FL>Created By</FL><RV>{doc.owner}</RV></div>
|
||||||
|
<div><FL>Created</FL><RV>{doc.creation ? new Date(doc.creation).toLocaleString() : '-'}</RV></div>
|
||||||
|
<div><FL>Modified</FL><RV>{doc.modified ? new Date(doc.modified).toLocaleString() : '-'}</RV></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Delivery Note"
|
||||||
|
docname={doc?.name || dnName || ''}
|
||||||
|
creationDate={doc?.creation}
|
||||||
|
createdBy={doc?.owner}
|
||||||
|
compact={false}
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeliveryNoteDetail;
|
||||||
265
pm_app/src/pages/DeliveryNoteList.tsx
Normal file
265
pm_app/src/pages/DeliveryNoteList.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaTruck, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport, FaEye, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import deliveryNoteService, { DeliveryNote } from '../services/deliveryNoteService';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function buildDeliveryNoteExportFilters(f: { search: string; status: string }) {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (f.search) filters.push(['Delivery Note', 'name', 'like', `%${f.search}%`]);
|
||||||
|
if (f.status === 'Draft') filters.push(['Delivery Note', 'docstatus', '=', 0]);
|
||||||
|
if (f.status === 'Submitted') filters.push(['Delivery Note', 'docstatus', '=', 1]);
|
||||||
|
if (f.status === 'Cancelled') filters.push(['Delivery Note', 'docstatus', '=', 2]);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusStyle(dn: DeliveryNote) {
|
||||||
|
if (dn.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||||
|
if (dn.docstatus === 1) return 'bg-green-100 text-green-700';
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
}
|
||||||
|
function getStatusLabel(dn: DeliveryNote) {
|
||||||
|
if (dn.docstatus === 2) return 'Cancelled';
|
||||||
|
if (dn.docstatus === 1) return dn.status || 'Submitted';
|
||||||
|
return 'Draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeliveryNoteList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [notes, setNotes] = useState<DeliveryNote[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [applied, setApplied] = useState({ search: '', status: '' });
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const didInitUrlSync = useRef(false);
|
||||||
|
const searchDebounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (f.search) filters.push(['Delivery Note', 'name', 'like', `%${f.search}%`]);
|
||||||
|
if (f.status === 'Draft') filters.push(['Delivery Note', 'docstatus', '=', 0]);
|
||||||
|
if (f.status === 'Submitted') filters.push(['Delivery Note', 'docstatus', '=', 1]);
|
||||||
|
if (f.status === 'Cancelled') filters.push(['Delivery Note', 'docstatus', '=', 2]);
|
||||||
|
const [rows, cnt] = await Promise.all([
|
||||||
|
deliveryNoteService.getDeliveryNotes({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||||
|
deliveryNoteService.getDeliveryNoteCount(filters),
|
||||||
|
]);
|
||||||
|
setNotes(rows); setTotal(cnt);
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Failed to load'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${page}|${applied.search}|${applied.status}`,
|
||||||
|
[page, applied.search, applied.status],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(notes, selectionResetKey);
|
||||||
|
|
||||||
|
// Auto-apply filters
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) {
|
||||||
|
didInitUrlSync.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setApplied((prev) => ({ ...prev, status: statusFilter }));
|
||||||
|
setPage(0);
|
||||||
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) return;
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
searchDebounceRef.current = window.setTimeout(() => {
|
||||||
|
setApplied((prev) => ({ ...prev, search: searchQuery }));
|
||||||
|
setPage(0);
|
||||||
|
}, 450);
|
||||||
|
return () => {
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const clear = () => { setSearchQuery(''); setStatusFilter(''); setApplied({ search: '', status: '' }); setPage(0); };
|
||||||
|
const hasActive = !!(applied.search || applied.status);
|
||||||
|
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||||
|
const handleView = (name: string) => navigate(`/delivery-notes/${encodeURIComponent(name)}`);
|
||||||
|
const handleEdit = (name: string) => navigate(`/delivery-notes/${encodeURIComponent(name)}?edit=1`);
|
||||||
|
const handleDuplicate = (name: string) => navigate(`/delivery-notes/new?duplicate=${encodeURIComponent(name)}`);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() =>
|
||||||
|
fetchAllRowsForExport({
|
||||||
|
doctype: 'Delivery Note',
|
||||||
|
filters: buildDeliveryNoteExportFilters(applied),
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
}),
|
||||||
|
[applied],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center"><FaTruck className="text-white text-base" /></div>
|
||||||
|
<div><h1 className="text-xl font-bold text-gray-900 dark:text-white">Delivery Notes</h1><p className="text-xs text-gray-500">{total} total</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-indigo-600 border border-gray-200 rounded-lg"><FaSync size={13} /></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={total === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/delivery-notes/new')} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium"><FaPlus size={11} /> New Note</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Delivery Note"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={notes.length}
|
||||||
|
totalCount={total}
|
||||||
|
pageData={notes}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="delivery_notes"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||||
|
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 text-white">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold"><FaSearch size={12} /> Filters {hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}</div>
|
||||||
|
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||||
|
</button>
|
||||||
|
{hasActive && (
|
||||||
|
<div className="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 flex flex-wrap gap-2 items-center border-b border-blue-100 dark:border-blue-900/30">
|
||||||
|
{applied.search && <span className="flex items-center gap-1 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">ID: {applied.search}<button onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
{applied.status && <span className="flex items-center gap-1 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">Status: {applied.status}<button onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
<button onClick={clear} className="text-xs text-blue-600 hover:underline ml-auto">Clear All</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Note ID</label>
|
||||||
|
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400" /></div>
|
||||||
|
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||||
|
<option value="">All</option><option value="Draft">Draft</option><option value="Submitted">Submitted</option><option value="Cancelled">Cancelled</option>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="w-10 px-4 py-3 text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllOnPage}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors"
|
||||||
|
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
>
|
||||||
|
{allOnPageSelected
|
||||||
|
? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} />
|
||||||
|
: someOnPageSelected
|
||||||
|
? (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<FaSquare size={18} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-2 h-0.5 bg-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Note ID</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Customer</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Grand Total</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-28"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{loading ? <tr><td colSpan={7} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||||
|
: notes.length === 0 ? <tr><td colSpan={7} className="text-center py-10 text-gray-400">No delivery notes found</td></tr>
|
||||||
|
: notes.map(dn => (
|
||||||
|
<tr key={dn.name} onClick={() => handleView(dn.name)} className={`cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${selectedRows.has(dn.name) ? 'bg-teal-50 dark:bg-teal-900/20' : ''}`}>
|
||||||
|
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleRow(dn.name)}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors"
|
||||||
|
aria-label={`Select ${dn.name}`}
|
||||||
|
>
|
||||||
|
{selectedRows.has(dn.name)
|
||||||
|
? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} />
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-medium text-gray-900 dark:text-white">{dn.name}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{dn.customer_name || dn.customer || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{dn.posting_date || '-'}</td>
|
||||||
|
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(dn)}`}>{getStatusLabel(dn)}</span></td>
|
||||||
|
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">{dn.currency || 'SAR'} {(dn.grand_total ?? 0).toFixed(2)}</td>
|
||||||
|
<td className="py-2 px-4" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => handleView(dn.name)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title="View"><FaEye /></button>
|
||||||
|
<button onClick={() => handleEdit(dn.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit"><FaEdit /></button>
|
||||||
|
<button onClick={() => handleDuplicate(dn.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate"><FaCopy /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||||
|
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeliveryNoteList;
|
||||||
337
pm_app/src/pages/EmployeeDetail.tsx
Normal file
337
pm_app/src/pages/EmployeeDetail.tsx
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FaArrowLeft, FaSave, FaEdit, FaTimes,
|
||||||
|
FaCheckCircle, FaTimesCircle, FaSpinner, FaUserTie,
|
||||||
|
FaChevronDown, FaChevronRight,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import masterService, { Employee } from '../services/masterService';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded-lg min-h-[38px] flex items-center">
|
||||||
|
{children || <span className="text-gray-400">-</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-teal-400';
|
||||||
|
|
||||||
|
const CollapsibleSection: React.FC<{ title: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ title, children, defaultOpen = true }) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<button type="button" onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{title}</span>
|
||||||
|
{open ? <FaChevronDown className="text-gray-400 text-xs" /> : <FaChevronRight className="text-gray-400 text-xs" />}
|
||||||
|
</button>
|
||||||
|
{open && <div className="px-6 pb-5">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (s?: string) => {
|
||||||
|
switch (s) {
|
||||||
|
case 'Active': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
case 'Inactive': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
case 'Left': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
case 'On Leave': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
const EmployeeDetail: React.FC = () => {
|
||||||
|
const { employeeName } = useParams<{ employeeName: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = employeeName === 'new';
|
||||||
|
|
||||||
|
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const emptyForm: Partial<Employee> = {
|
||||||
|
first_name: '', middle_name: '', last_name: '', salutation: '',
|
||||||
|
gender: '', date_of_birth: '', date_of_joining: '', status: 'Active',
|
||||||
|
company: '', designation: '', branch: '', department: '', reports_to: '', employee_number: '',
|
||||||
|
};
|
||||||
|
const [form, setForm] = useState<Partial<Employee>>(emptyForm);
|
||||||
|
|
||||||
|
const syncForm = useCallback((e: Employee) => {
|
||||||
|
setForm({
|
||||||
|
first_name: e.first_name || '',
|
||||||
|
middle_name: e.middle_name || '',
|
||||||
|
last_name: e.last_name || '',
|
||||||
|
salutation: e.salutation || '',
|
||||||
|
gender: e.gender || '',
|
||||||
|
date_of_birth: e.date_of_birth || '',
|
||||||
|
date_of_joining: e.date_of_joining || '',
|
||||||
|
status: e.status || 'Active',
|
||||||
|
company: e.company || '',
|
||||||
|
designation: e.designation || '',
|
||||||
|
branch: e.branch || '',
|
||||||
|
department: e.department || '',
|
||||||
|
reports_to: e.reports_to || '',
|
||||||
|
employee_number: e.employee_number || '',
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew && employeeName) {
|
||||||
|
setLoading(true);
|
||||||
|
masterService.getEmployee(employeeName)
|
||||||
|
.then(e => { setEmployee(e); syncForm(e); })
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [employeeName, isNew, syncForm]);
|
||||||
|
|
||||||
|
const set = (k: keyof Employee, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.first_name?.trim()) { toast.error('First Name is required'); return; }
|
||||||
|
if (!form.gender) { toast.error('Gender is required'); return; }
|
||||||
|
if (!form.date_of_birth) { toast.error('Date of Birth is required'); return; }
|
||||||
|
if (!form.date_of_joining) { toast.error('Date of Joining is required'); return; }
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
const created = await masterService.createEmployee(form);
|
||||||
|
toast.success('Employee created', { icon: <FaCheckCircle /> });
|
||||||
|
navigate(`/employees/${encodeURIComponent(created.name)}`);
|
||||||
|
} else {
|
||||||
|
const updated = await masterService.updateEmployee(employeeName!, form);
|
||||||
|
toast.success('Employee updated', { icon: <FaCheckCircle /> });
|
||||||
|
setEmployee(updated); syncForm(updated); setIsEditing(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Save failed', { icon: <FaTimesCircle /> });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => { if (employee) syncForm(employee); setIsEditing(false); };
|
||||||
|
const editable = isNew || isEditing;
|
||||||
|
|
||||||
|
const displayName = employee?.employee_name
|
||||||
|
|| [employee?.first_name, employee?.last_name].filter(Boolean).join(' ')
|
||||||
|
|| employeeName;
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center items-center min-h-[400px]"><FaSpinner className="animate-spin text-teal-500 text-3xl" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3500} />
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500 dark:text-gray-400">
|
||||||
|
<button onClick={() => navigate('/projects')} className="hover:text-teal-600">Project Management</button>
|
||||||
|
<span>/</span>
|
||||||
|
<button onClick={() => navigate('/employees')} className="hover:text-teal-600">Employees</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Employee' : displayName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/employees')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"><FaArrowLeft /></button>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<FaUserTie className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{isNew ? 'New Employee' : displayName}
|
||||||
|
</h1>
|
||||||
|
{!isNew && employee && (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded font-medium ${statusBadge(employee.status)}`}>
|
||||||
|
{employee.status || 'Active'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isNew && !isEditing && (
|
||||||
|
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm">
|
||||||
|
<FaEdit /> Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 text-sm font-medium">
|
||||||
|
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{!isNew && <button onClick={handleCancel} className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 text-sm"><FaTimes /></button>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded text-red-700 dark:text-red-300 text-sm">{error}</div>}
|
||||||
|
|
||||||
|
{/* ── Overview ── */}
|
||||||
|
<div className="p-6 grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-4 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
{/* Series (read-only) */}
|
||||||
|
<div>
|
||||||
|
<FL>Series</FL>
|
||||||
|
<RV>HR-EMP-</RV>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gender */}
|
||||||
|
<div>
|
||||||
|
<FL required>Gender</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={form.gender || ''} onChange={e => set('gender', e.target.value)} className={inputCls}>
|
||||||
|
<option value="">Select gender</option>
|
||||||
|
<option>Male</option><option>Female</option><option>Other</option><option>Prefer not to say</option>
|
||||||
|
</select>
|
||||||
|
: <RV>{form.gender}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date of Joining */}
|
||||||
|
<div>
|
||||||
|
<FL required>Date of Joining</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="date" value={form.date_of_joining || ''} onChange={e => set('date_of_joining', e.target.value)} className={inputCls} />
|
||||||
|
: <RV>{form.date_of_joining}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* First Name */}
|
||||||
|
<div>
|
||||||
|
<FL required>First Name</FL>
|
||||||
|
{editable
|
||||||
|
? <input value={form.first_name || ''} onChange={e => set('first_name', e.target.value)} className={inputCls} placeholder="First name" />
|
||||||
|
: <RV>{form.first_name}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date of Birth */}
|
||||||
|
<div>
|
||||||
|
<FL required>Date of Birth</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="date" value={form.date_of_birth || ''} onChange={e => set('date_of_birth', e.target.value)} className={inputCls} />
|
||||||
|
: <RV>{form.date_of_birth}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<FL>Status</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={form.status || 'Active'} onChange={e => set('status', e.target.value)} className={inputCls}>
|
||||||
|
<option>Active</option><option>Inactive</option><option>Left</option><option>On Leave</option>
|
||||||
|
</select>
|
||||||
|
: <RV>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusBadge(form.status)}`}>{form.status || 'Active'}</span>
|
||||||
|
</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Name */}
|
||||||
|
<div>
|
||||||
|
<FL>Middle Name</FL>
|
||||||
|
{editable
|
||||||
|
? <input value={form.middle_name || ''} onChange={e => set('middle_name', e.target.value)} className={inputCls} placeholder="Middle name" />
|
||||||
|
: <RV>{form.middle_name}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Salutation */}
|
||||||
|
<div>
|
||||||
|
<FL>Salutation</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={form.salutation || ''} onChange={e => set('salutation', e.target.value)} className={inputCls}>
|
||||||
|
<option value="">Select</option>
|
||||||
|
<option>Mr.</option><option>Ms.</option><option>Mrs.</option><option>Dr.</option><option>Prof.</option>
|
||||||
|
</select>
|
||||||
|
: <RV>{form.salutation}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Name */}
|
||||||
|
<div>
|
||||||
|
<FL>Last Name</FL>
|
||||||
|
{editable
|
||||||
|
? <input value={form.last_name || ''} onChange={e => set('last_name', e.target.value)} className={inputCls} placeholder="Last name" />
|
||||||
|
: <RV>{form.last_name}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Company Details ── */}
|
||||||
|
<CollapsibleSection title="Company Details">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-4">
|
||||||
|
|
||||||
|
{/* Hospital (= company field) */}
|
||||||
|
<div>
|
||||||
|
<FL required>Hospital</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Hospital" hideLabel doctype="Company" value={form.company || ''} onChange={v => set('company', v)} placeholder="Select hospital…" />
|
||||||
|
: <RV>{form.company}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Designation */}
|
||||||
|
<div>
|
||||||
|
<FL>Designation</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Designation" hideLabel doctype="Designation" value={form.designation || ''} onChange={v => set('designation', v)} placeholder="Select designation…" />
|
||||||
|
: <RV>{form.designation}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Department */}
|
||||||
|
<div>
|
||||||
|
<FL>Department</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Department" hideLabel doctype="Department" value={form.department || ''} onChange={v => set('department', v)} placeholder="Select department…" />
|
||||||
|
: <RV>{form.department}</RV>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Employee Number */}
|
||||||
|
<div>
|
||||||
|
<FL>Employee Number</FL>
|
||||||
|
{editable
|
||||||
|
? <input value={form.employee_number || ''} onChange={e => set('employee_number', e.target.value)} className={inputCls} placeholder="e.g. EMP-001" />
|
||||||
|
: <RV>{form.employee_number}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* ── Meta ── */}
|
||||||
|
{!isNew && employee && (
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100 dark:border-gray-700 grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Created By</span>{employee.owner}</div>
|
||||||
|
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Created</span>{employee.creation ? new Date(employee.creation).toLocaleString() : '-'}</div>
|
||||||
|
<div><span className="font-medium block text-gray-500 dark:text-gray-400">Modified</span>{employee.modified ? new Date(employee.modified).toLocaleString() : '-'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Employee"
|
||||||
|
docname={employee?.name || employeeName || ''}
|
||||||
|
creationDate={employee?.creation}
|
||||||
|
createdBy={employee?.owner}
|
||||||
|
compact={false}
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmployeeDetail;
|
||||||
242
pm_app/src/pages/EmployeeList.tsx
Normal file
242
pm_app/src/pages/EmployeeList.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaPlus, FaSearch, FaSpinner, FaUserTie, FaArrowLeft, FaFileExport } from 'react-icons/fa';
|
||||||
|
import masterService, { type Employee } from '../services/masterService';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const PAGE = 20;
|
||||||
|
|
||||||
|
const statusBadge = (status?: string) => {
|
||||||
|
const s = (status || '').toLowerCase();
|
||||||
|
if (s === 'active') return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
if (s === 'inactive' || s === 'left') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmployeeList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const f: Record<string, unknown> = {};
|
||||||
|
if (search.trim()) {
|
||||||
|
f.employee_name = ['like', `%${search.trim()}%`];
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (p = 0) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [{ data }, cnt] = await Promise.all([
|
||||||
|
masterService.getEmployees({ limit_start: p * PAGE, limit_page_length: PAGE, filters: apiFilters }),
|
||||||
|
masterService.getEmployeeCount(apiFilters),
|
||||||
|
]);
|
||||||
|
setEmployees(data);
|
||||||
|
setTotal(cnt);
|
||||||
|
setPage(p);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to load employees');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(0);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(employees, selectionResetKey);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() => fetchAllRowsForExport({ doctype: 'Employee', filters: apiFilters, orderBy: 'modified desc' }),
|
||||||
|
[apiFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects')}
|
||||||
|
className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<FaArrowLeft />
|
||||||
|
</button>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center">
|
||||||
|
<FaUserTie className="text-white text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Employees</h1>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{total} total</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex flex-wrap gap-2 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={(total === 0 && selectedRows.size === 0) || loading}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearch}
|
||||||
|
placeholder="Search employees…"
|
||||||
|
className="pl-8 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-52 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/employees/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<FaPlus size={11} /> New Employee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Employee"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={employees.length}
|
||||||
|
totalCount={total}
|
||||||
|
pageData={employees}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="employees"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<FaSpinner className="animate-spin text-teal-500 text-2xl" />
|
||||||
|
</div>
|
||||||
|
) : employees.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-gray-400 dark:text-gray-500">
|
||||||
|
<FaUserTie className="mx-auto text-4xl mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">No employees found</p>
|
||||||
|
<button type="button" onClick={() => navigate('/employees/new')} className="mt-3 text-sm text-teal-600 hover:underline">
|
||||||
|
+ Create first employee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_120px] bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
|
<div className="px-2 py-3 flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-teal-600 focus:ring-teal-500"
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
ref={el => {
|
||||||
|
if (el) el.indeterminate = someOnPageSelected;
|
||||||
|
}}
|
||||||
|
onChange={toggleAllOnPage}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">Name</div>
|
||||||
|
<div className="px-4 py-3">ID</div>
|
||||||
|
<div className="px-4 py-3">Department</div>
|
||||||
|
<div className="px-4 py-3">Company</div>
|
||||||
|
<div className="px-4 py-3">Status</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{employees.map((emp) => (
|
||||||
|
<div
|
||||||
|
key={emp.name}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => navigate(`/employees/${encodeURIComponent(emp.name)}`)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/employees/${encodeURIComponent(emp.name)}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_120px] w-full text-left hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors cursor-pointer ${selectedRows.has(emp.name) ? 'bg-teal-50/80 dark:bg-teal-900/20' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="px-2 py-3 flex items-center justify-center" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-teal-600 focus:ring-teal-500"
|
||||||
|
checked={selectedRows.has(emp.name)}
|
||||||
|
onChange={() => toggleRow(emp.name)}
|
||||||
|
aria-label={`Select ${emp.name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-teal-600 dark:text-teal-400">{emp.employee_name || emp.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{emp.name}</div>
|
||||||
|
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{emp.department || '—'}</div>
|
||||||
|
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{emp.company || '—'}</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge(emp.status)}`}>
|
||||||
|
{emp.status || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>
|
||||||
|
Page {page + 1} · Showing {employees.length} of {total}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => load(page - 1)} disabled={page === 0} className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => load(page + 1)}
|
||||||
|
disabled={(page + 1) * PAGE >= total}
|
||||||
|
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmployeeList;
|
||||||
825
pm_app/src/pages/Login.tsx
Normal file
825
pm_app/src/pages/Login.tsx
Normal file
@ -0,0 +1,825 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { loadFrappeTranslations } from '../i18n';
|
||||||
|
import { bootstrapFrappeUserFromSession } from '../utils/bootstrapFrappeUserFromSession';
|
||||||
|
|
||||||
|
interface LoginFormData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AFTER_PASSWORD_RESET_KEY = 'asm_show_after_password_reset';
|
||||||
|
const TWO_FACTOR_TMP_ID_KEY = 'asm_login_tmp_id';
|
||||||
|
|
||||||
|
type LoginStep = 'credentials' | 'otp';
|
||||||
|
|
||||||
|
const Login: React.FC = () => {
|
||||||
|
const [formData, setFormData] = useState<LoginFormData>({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [checkingSession, setCheckingSession] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [forgotOpen, setForgotOpen] = useState(false);
|
||||||
|
const [forgotEmail, setForgotEmail] = useState('');
|
||||||
|
const [forgotLoading, setForgotLoading] = useState(false);
|
||||||
|
const [forgotError, setForgotError] = useState<string | null>(null);
|
||||||
|
const [forgotMessage, setForgotMessage] = useState(false);
|
||||||
|
const [pwdResetBusy, setPwdResetBusy] = useState(false);
|
||||||
|
const [postResetBanner, setPostResetBanner] = useState(false);
|
||||||
|
const [loginStep, setLoginStep] = useState<LoginStep>('credentials');
|
||||||
|
const [tmpId, setTmpId] = useState<string | null>(null);
|
||||||
|
const [otpCode, setOtpCode] = useState('');
|
||||||
|
const [verification, setVerification] = useState<{
|
||||||
|
method: string;
|
||||||
|
setup?: boolean;
|
||||||
|
prompt?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const manualLoginHandledRef = useRef(false);
|
||||||
|
const forgotAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const closeForgotModal = useCallback(() => {
|
||||||
|
forgotAbortRef.current?.abort();
|
||||||
|
forgotAbortRef.current = null;
|
||||||
|
setForgotOpen(false);
|
||||||
|
setForgotLoading(false);
|
||||||
|
setForgotError(null);
|
||||||
|
setForgotMessage(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openForgotModal = useCallback(() => {
|
||||||
|
setForgotOpen(true);
|
||||||
|
setForgotEmail(formData.email);
|
||||||
|
setForgotError(null);
|
||||||
|
setForgotMessage(false);
|
||||||
|
setForgotLoading(false);
|
||||||
|
setError(null);
|
||||||
|
}, [formData.email]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!forgotOpen) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeForgotModal();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [forgotOpen, closeForgotModal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (
|
||||||
|
typeof sessionStorage !== 'undefined' &&
|
||||||
|
sessionStorage.getItem(AFTER_PASSWORD_RESET_KEY) === '1'
|
||||||
|
) {
|
||||||
|
sessionStorage.removeItem(AFTER_PASSWORD_RESET_KEY);
|
||||||
|
if (!cancelled) setPostResetBanner(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
typeof window !== 'undefined' ? window.location.search : ''
|
||||||
|
);
|
||||||
|
if (params.get('manual_login') === '1' && !manualLoginHandledRef.current) {
|
||||||
|
manualLoginHandledRef.current = true;
|
||||||
|
if (!cancelled) setPwdResetBusy(true);
|
||||||
|
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('frappe_session_id');
|
||||||
|
|
||||||
|
const baseURL = import.meta.env.VITE_FRAPPE_BASE_URL || '';
|
||||||
|
let csrf =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? (window as { csrf_token?: string }).csrf_token
|
||||||
|
: undefined;
|
||||||
|
if (!csrf) {
|
||||||
|
try {
|
||||||
|
const csrfRes = await fetch(`${baseURL}/api/method/frappe.sessions.get_csrf_token`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (csrfRes.ok) {
|
||||||
|
const csrfData = (await csrfRes.json()) as { message?: string };
|
||||||
|
if (typeof csrfData.message === 'string') csrf = csrfData.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
|
if (csrf) headers['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
try {
|
||||||
|
await fetch(`${baseURL}/api/method/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof sessionStorage !== 'undefined') {
|
||||||
|
sessionStorage.setItem(AFTER_PASSWORD_RESET_KEY, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
setPwdResetBusy(false);
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('user')) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCheckingSession(false);
|
||||||
|
navigate('/projects', { replace: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await bootstrapFrappeUserFromSession();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
try {
|
||||||
|
await loadFrappeTranslations();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Could not load translations after session bootstrap:', err);
|
||||||
|
}
|
||||||
|
if (!cancelled) navigate('/projects', { replace: true });
|
||||||
|
}
|
||||||
|
if (!cancelled) setCheckingSession(false);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [navigate, location.pathname, location.search]);
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.BASE_URL || '/';
|
||||||
|
const logoVersion = import.meta.env.DEV
|
||||||
|
? `?v=${Date.now()}`
|
||||||
|
: `?v=1781184737`; // Auto-updated by build script
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
}));
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeLogin = useCallback(
|
||||||
|
async (user: { full_name?: string; user_id?: string; sid?: string; email?: string }) => {
|
||||||
|
const apiService = (await import('../services/apiService')).default;
|
||||||
|
const userData = {
|
||||||
|
...user,
|
||||||
|
email: user.email || formData.email,
|
||||||
|
};
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
if (user.sid) {
|
||||||
|
apiService.setSessionId(user.sid);
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(TWO_FACTOR_TMP_ID_KEY);
|
||||||
|
try {
|
||||||
|
await loadFrappeTranslations();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Could not load translations after login:', err);
|
||||||
|
}
|
||||||
|
navigate('/projects');
|
||||||
|
},
|
||||||
|
[formData.email, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiService = (await import('../services/apiService')).default;
|
||||||
|
const result = await apiService.login(formData);
|
||||||
|
|
||||||
|
if (result.status === 'two_factor_required') {
|
||||||
|
sessionStorage.setItem(TWO_FACTOR_TMP_ID_KEY, result.tmp_id);
|
||||||
|
setTmpId(result.tmp_id);
|
||||||
|
setVerification(result.verification);
|
||||||
|
setLoginStep('otp');
|
||||||
|
setOtpCode('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await completeLogin(result.user);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
const message = err instanceof Error ? err.message : t('login.loginFailed');
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOtpSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const storedTmpId = tmpId || sessionStorage.getItem(TWO_FACTOR_TMP_ID_KEY);
|
||||||
|
if (!storedTmpId) {
|
||||||
|
setError(t('login.twoFactorSessionExpired'));
|
||||||
|
setLoginStep('credentials');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!otpCode.trim()) {
|
||||||
|
setError(t('login.twoFactorCodeRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const apiService = (await import('../services/apiService')).default;
|
||||||
|
const result = await apiService.verifyLoginOtp(storedTmpId, otpCode);
|
||||||
|
if (result.status === 'logged_in') {
|
||||||
|
await completeLogin(result.user);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('login.twoFactorInvalid');
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const backToCredentials = () => {
|
||||||
|
sessionStorage.removeItem(TWO_FACTOR_TMP_ID_KEY);
|
||||||
|
setLoginStep('credentials');
|
||||||
|
setTmpId(null);
|
||||||
|
setVerification(null);
|
||||||
|
setOtpCode('');
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoLogin = async () => {
|
||||||
|
const demoUser = {
|
||||||
|
full_name: 'Demo User',
|
||||||
|
email: 'demo@seeraarabia.com',
|
||||||
|
user_image: '',
|
||||||
|
roles: ['System Manager', 'Administrator']
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('user', JSON.stringify(demoUser));
|
||||||
|
|
||||||
|
// Load translations from Frappe after demo login
|
||||||
|
try {
|
||||||
|
await loadFrappeTranslations();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Could not load translations after demo login:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/projects');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForgotPasswordSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const userVal = forgotEmail.trim();
|
||||||
|
if (!userVal) {
|
||||||
|
setForgotError(t('login.forgotPasswordUserRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forgotAbortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
forgotAbortRef.current = controller;
|
||||||
|
|
||||||
|
setForgotLoading(true);
|
||||||
|
setForgotError(null);
|
||||||
|
setForgotMessage(false);
|
||||||
|
const forgotApi = await import('../services/apiService');
|
||||||
|
try {
|
||||||
|
await forgotApi.default.requestPasswordReset(userVal, controller.signal);
|
||||||
|
setForgotMessage(true);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
setForgotError(t('login.forgotPasswordTimeout'));
|
||||||
|
} else if (err instanceof forgotApi.ApiError) {
|
||||||
|
if (err.code === 'USER_NOT_FOUND') setForgotError(t('login.forgotPasswordNotFound'));
|
||||||
|
else if (err.code === 'RESET_NOT_ALLOWED') setForgotError(t('login.forgotPasswordCannotReset'));
|
||||||
|
else if (err.code === 'FORBIDDEN') setForgotError(t('login.forgotPasswordFailed'));
|
||||||
|
else if (err.code === 'EMPTY_EMAIL') setForgotError(t('login.forgotPasswordUserRequired'));
|
||||||
|
else setForgotError(t('login.forgotPasswordFailed'));
|
||||||
|
} else {
|
||||||
|
setForgotError(t('login.forgotPasswordFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (forgotAbortRef.current === controller) {
|
||||||
|
forgotAbortRef.current = null;
|
||||||
|
}
|
||||||
|
setForgotLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkingSession || pwdResetBusy) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="h-10 w-10 animate-spin text-indigo-600"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">
|
||||||
|
{pwdResetBusy ? t('login.finishingSignOut') : t('common.loading')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="w-32 h-32 flex items-center justify-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-4">
|
||||||
|
<img
|
||||||
|
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png${logoVersion}`}
|
||||||
|
alt="Seera Arabia"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
const container = e.currentTarget.parentElement;
|
||||||
|
if (container) {
|
||||||
|
container.classList.add('bg-gradient-to-br', 'from-indigo-600', 'to-purple-600');
|
||||||
|
}
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
const nextSibling = e.currentTarget.nextElementSibling;
|
||||||
|
if (nextSibling) {
|
||||||
|
nextSibling.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<svg className="w-20 h-20 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white" fillOpacity="0.9"/>
|
||||||
|
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="white" fillOpacity="0.7"/>
|
||||||
|
<path d="M12 12V17" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-center text-3xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('login.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
|
||||||
|
{t('login.subtitle')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{loginStep === 'otp' ? t('login.twoFactorTitle') : t('login.signIn')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-6">
|
||||||
|
{postResetBanner && loginStep === 'credentials' && (
|
||||||
|
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
|
||||||
|
<p className="text-sm text-green-800 dark:text-green-300">
|
||||||
|
{t('login.afterPasswordResetSignIn')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loginStep === 'otp' ? (
|
||||||
|
<form className="space-y-6" onSubmit={handleOtpSubmit}>
|
||||||
|
<div className="rounded-md bg-indigo-50 p-4 text-sm text-indigo-900 dark:bg-indigo-900/20 dark:text-indigo-200">
|
||||||
|
{verification?.method === 'Email' && verification.prompt ? (
|
||||||
|
<p>{verification.prompt}</p>
|
||||||
|
) : verification?.method === 'OTP App' && verification.setup ? (
|
||||||
|
<p>{t('login.twoFactorOtpAppEnter')}</p>
|
||||||
|
) : verification?.method === 'OTP App' && !verification.setup ? (
|
||||||
|
<p>{t('login.twoFactorOtpAppSetupIncomplete')}</p>
|
||||||
|
) : (
|
||||||
|
<p>{t('login.twoFactorOtpAppEnter')}</p>
|
||||||
|
)}
|
||||||
|
{verification?.method === 'Email' && (
|
||||||
|
<p className="mt-2 text-xs text-indigo-800 dark:text-indigo-300">
|
||||||
|
{t('login.twoFactorEmailQrHint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="otp" className="sr-only">
|
||||||
|
{t('login.twoFactorCodeLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="otp"
|
||||||
|
name="otp"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="relative block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-center text-lg tracking-widest text-gray-900 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
||||||
|
placeholder={t('login.twoFactorCodePlaceholder')}
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(ev) => {
|
||||||
|
setOtpCode(ev.target.value.replace(/\D/g, '').slice(0, 6));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||||
|
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="group relative flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? t('common.loading') : t('login.twoFactorVerify')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={backToCredentials}
|
||||||
|
className="w-full text-center text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
|
||||||
|
>
|
||||||
|
{t('login.twoFactorBackToLogin')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="sr-only">
|
||||||
|
{t('common.email')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder={t('login.emailPlaceholder')}
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
{t('common.password')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder={t('login.passwordPlaceholder')}
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{t('common.loading')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t('common.login')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loginStep === 'credentials' && (
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openForgotModal}
|
||||||
|
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 focus:outline-none focus:underline"
|
||||||
|
>
|
||||||
|
{t('login.forgotPassword')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="hidden space-y-3" aria-hidden="true">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">{t('login.or')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
🚀 {t('login.demoLogin')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{forgotOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={closeForgotModal}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md rounded-lg border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="forgot-password-title"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeForgotModal}
|
||||||
|
className="absolute right-3 top-3 rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||||
|
aria-label={t('login.forgotPasswordClose')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h3
|
||||||
|
id="forgot-password-title"
|
||||||
|
className="pr-8 text-lg font-semibold text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{t('login.forgotPasswordTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-gray-600 dark:text-gray-400">
|
||||||
|
{t('login.forgotPasswordHint')}
|
||||||
|
</p>
|
||||||
|
<form className="mt-4 space-y-3" onSubmit={handleForgotPasswordSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="reset-user"
|
||||||
|
autoComplete="username"
|
||||||
|
value={forgotEmail}
|
||||||
|
onChange={(ev) => {
|
||||||
|
setForgotEmail(ev.target.value);
|
||||||
|
setForgotError(null);
|
||||||
|
setForgotMessage(false);
|
||||||
|
}}
|
||||||
|
className="relative block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-400"
|
||||||
|
placeholder={t('login.forgotPasswordUserPlaceholder')}
|
||||||
|
/>
|
||||||
|
{forgotError && (
|
||||||
|
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
|
||||||
|
{forgotError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{forgotMessage && (
|
||||||
|
<div className="rounded-md bg-green-50 p-3 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-300">
|
||||||
|
{t('login.forgotPasswordSentSuccess')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeForgotModal}
|
||||||
|
className="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{t('login.forgotPasswordClose')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={forgotLoading}
|
||||||
|
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{forgotLoading ? t('common.loading') : t('login.forgotPasswordSubmit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// import React, { useState } from 'react';
|
||||||
|
// import { useNavigate } from 'react-router-dom';
|
||||||
|
// import { useAuth } from '../hooks/useApi';
|
||||||
|
|
||||||
|
// interface LoginFormData {
|
||||||
|
// email: string;
|
||||||
|
// password: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const Login: React.FC = () => {
|
||||||
|
// const [formData, setFormData] = useState<LoginFormData>({
|
||||||
|
// email: '',
|
||||||
|
// password: '',
|
||||||
|
// });
|
||||||
|
// const [loading, setLoading] = useState(false);
|
||||||
|
// const [error, setError] = useState<string | null>(null);
|
||||||
|
// const navigate = useNavigate();
|
||||||
|
// const { login } = useAuth();
|
||||||
|
|
||||||
|
// const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
// const { name, value } = e.target;
|
||||||
|
// setFormData(prev => ({
|
||||||
|
// ...prev,
|
||||||
|
// [name]: value,
|
||||||
|
// }));
|
||||||
|
// setError(null);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
// e.preventDefault();
|
||||||
|
// setLoading(true);
|
||||||
|
// setError(null);
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const response = await login(formData);
|
||||||
|
|
||||||
|
// if (response && response.message) {
|
||||||
|
// // Store user info in localStorage with email field
|
||||||
|
// const userData = {
|
||||||
|
// ...response.message,
|
||||||
|
// email: formData.email // Ensure email is stored
|
||||||
|
// };
|
||||||
|
// localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
// navigate('/projects');
|
||||||
|
// } else {
|
||||||
|
// setError('Login failed. Please check your credentials.');
|
||||||
|
// }
|
||||||
|
// } catch (err: any) {
|
||||||
|
// setError(err.message || 'Login failed. Please try again.');
|
||||||
|
// } finally {
|
||||||
|
// setLoading(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleDemoLogin = () => {
|
||||||
|
// // Create dummy user data for demo purposes
|
||||||
|
// const demoUser = {
|
||||||
|
// full_name: 'Demo User',
|
||||||
|
// email: 'demo@seeraarabia.com',
|
||||||
|
// user_image: '',
|
||||||
|
// roles: ['System Manager', 'Administrator']
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // Store demo user in localStorage
|
||||||
|
// localStorage.setItem('user', JSON.stringify(demoUser));
|
||||||
|
// navigate('/projects');
|
||||||
|
// };
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
// <div className="max-w-md w-full space-y-8">
|
||||||
|
// <div>
|
||||||
|
// <div className="flex justify-center mb-6">
|
||||||
|
// <div className="w-32 h-32 flex items-center justify-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-4">
|
||||||
|
// {/* Seera Arabia Logo */}
|
||||||
|
// <img
|
||||||
|
// src="/seera-logo.png?v=1765198405"
|
||||||
|
// alt="Seera Arabia"
|
||||||
|
// className="w-full h-full object-contain"
|
||||||
|
// onError={(e) => {
|
||||||
|
// // Fallback to gradient background with SVG if image not found
|
||||||
|
// const container = e.currentTarget.parentElement;
|
||||||
|
// if (container) {
|
||||||
|
// container.classList.add('bg-gradient-to-br', 'from-indigo-600', 'to-purple-600');
|
||||||
|
// }
|
||||||
|
// e.currentTarget.style.display = 'none';
|
||||||
|
// e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
||||||
|
// }}
|
||||||
|
// />
|
||||||
|
// <svg className="w-20 h-20 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
// <path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white" fillOpacity="0.9"/>
|
||||||
|
// <path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="white" fillOpacity="0.7"/>
|
||||||
|
// <path d="M12 12V17" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
// </svg>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// <h2 className="text-center text-3xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
// Seera Arabia
|
||||||
|
// </h2>
|
||||||
|
// <p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
|
||||||
|
// Asset Management System
|
||||||
|
// </p>
|
||||||
|
// <p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
// Sign in to continue
|
||||||
|
// </p>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
// <div className="rounded-md shadow-sm -space-y-px">
|
||||||
|
// <div>
|
||||||
|
// <label htmlFor="email" className="sr-only">
|
||||||
|
// Email
|
||||||
|
// </label>
|
||||||
|
// <input
|
||||||
|
// id="email"
|
||||||
|
// name="email"
|
||||||
|
// type="email"
|
||||||
|
// required
|
||||||
|
// className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||||
|
// placeholder="Email"
|
||||||
|
// value={formData.email}
|
||||||
|
// onChange={handleChange}
|
||||||
|
// />
|
||||||
|
// </div>
|
||||||
|
// <div>
|
||||||
|
// <label htmlFor="password" className="sr-only">
|
||||||
|
// Password
|
||||||
|
// </label>
|
||||||
|
// <input
|
||||||
|
// id="password"
|
||||||
|
// name="password"
|
||||||
|
// type="password"
|
||||||
|
// required
|
||||||
|
// className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||||
|
// placeholder="Password"
|
||||||
|
// value={formData.password}
|
||||||
|
// onChange={handleChange}
|
||||||
|
// />
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {error && (
|
||||||
|
// <div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
// <div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||||
|
// </div>
|
||||||
|
// )}
|
||||||
|
|
||||||
|
// <div className="space-y-3">
|
||||||
|
// <button
|
||||||
|
// type="submit"
|
||||||
|
// disabled={loading}
|
||||||
|
// className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
// >
|
||||||
|
// {loading ? (
|
||||||
|
// <div className="flex items-center">
|
||||||
|
// <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
// <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
// <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
// </svg>
|
||||||
|
// Signing in...
|
||||||
|
// </div>
|
||||||
|
// ) : (
|
||||||
|
// 'Sign in'
|
||||||
|
// )}
|
||||||
|
// </button>
|
||||||
|
|
||||||
|
// <div className="relative">
|
||||||
|
// <div className="absolute inset-0 flex items-center">
|
||||||
|
// <div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||||
|
// </div>
|
||||||
|
// <div className="relative flex justify-center text-sm">
|
||||||
|
// <span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">or</span>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <button
|
||||||
|
// type="button"
|
||||||
|
// onClick={handleDemoLogin}
|
||||||
|
// className="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
// >
|
||||||
|
// 🚀 {t('login.demoLogin')}
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </form>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export default Login;
|
||||||
610
pm_app/src/pages/MaterialRequestDetail.tsx
Normal file
610
pm_app/src/pages/MaterialRequestDetail.tsx
Normal file
@ -0,0 +1,610 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||||
|
FaSpinner, FaBoxes, FaPaperPlane, FaChevronDown, FaChevronRight, FaPencilAlt, FaShoppingBag,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import materialRequestService, { MaterialRequest, MaterialRequestItem } from '../services/materialRequestService';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
import WorkflowActions from '../components/WorkflowActions';
|
||||||
|
import workflowService from '../services/workflowService';
|
||||||
|
import { DEFAULT_COMPANY } from '../constants/orgDefaults';
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded-lg min-h-[38px] flex items-center">
|
||||||
|
{children || <span className="text-gray-400">-</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-400';
|
||||||
|
const inlineTxt = 'w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-orange-400';
|
||||||
|
const inlineNum = 'w-full px-2 py-1 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-orange-400';
|
||||||
|
const editorInput = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-400';
|
||||||
|
const editorNum = 'w-full px-3 py-2 text-sm text-right border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-400';
|
||||||
|
|
||||||
|
// ── Collapsible section (matches Project/Task/Timesheet style) ─────────────────
|
||||||
|
const CollapsibleSection: React.FC<{
|
||||||
|
title: string; icon?: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode;
|
||||||
|
}> = ({ title, icon, defaultOpen = true, children }) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||||||
|
<button type="button" onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3.5 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors text-left">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{icon}<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
{open ? <FaChevronDown className="text-gray-400 text-xs flex-shrink-0" /> : <FaChevronRight className="text-gray-400 text-xs flex-shrink-0" />}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-5 py-5 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700/50">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Collapsible group (inside editor) ─────────────────────────────────────────
|
||||||
|
const RGroup: React.FC<{ label: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ label, children, defaultOpen = false }) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden mt-3">
|
||||||
|
<button type="button" onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider bg-gray-50 dark:bg-gray-800/80 hover:bg-gray-100 transition-colors text-left">
|
||||||
|
{open ? <FaChevronDown size={9} /> : <FaChevronRight size={9} />}{label}
|
||||||
|
</button>
|
||||||
|
{open && <div className="p-3 grid grid-cols-1 sm:grid-cols-2 gap-3 bg-white dark:bg-gray-800">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Create Dropdown ───────────────────────────────────────────────────────────
|
||||||
|
const CreateDropdown: React.FC<{ items: { label: string; icon: React.ReactNode; onClick: () => void }[] }> = ({ items }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', h);
|
||||||
|
return () => document.removeEventListener('mousedown', h);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button onClick={() => setOpen(o => !o)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 text-sm font-medium shadow-sm">
|
||||||
|
Create <FaChevronDown size={10} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-1 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 py-1.5">
|
||||||
|
<div className="px-3 py-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-gray-700 mb-1">Create from this request</div>
|
||||||
|
{items.map(({ label, icon, onClick }) => (
|
||||||
|
<button key={label} onClick={() => { onClick(); setOpen(false); }}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 hover:text-amber-700 transition-colors text-left">
|
||||||
|
<span className="text-gray-400">{icon}</span>{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── MR Item Row Editor ────────────────────────────────────────────────────────
|
||||||
|
const MRItemRowEditor: React.FC<{
|
||||||
|
item: Partial<MaterialRequestItem>;
|
||||||
|
rowNo: number;
|
||||||
|
onChange: (k: string, v: any) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onInsertBelow: () => void;
|
||||||
|
}> = ({ item, rowNo, onChange, onClose, onDelete, onInsertBelow }) => {
|
||||||
|
const set = (k: string, v: any) => onChange(k, v);
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="p-0">
|
||||||
|
<div className="bg-amber-50/60 dark:bg-amber-900/10 border-b border-amber-200 dark:border-amber-800 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-amber-700 dark:text-amber-300 uppercase tracking-wider">Editing Row #{rowNo}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={onInsertBelow} className="px-2 py-1 text-[11px] bg-amber-600 text-white rounded hover:bg-amber-700">Insert Below</button>
|
||||||
|
<button onClick={onDelete} className="px-2 py-1 text-[11px] bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||||
|
<button onClick={onClose} className="px-2 py-1 text-[11px] bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300">ESC</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div><FL required>Item Code</FL><LinkField label="Item Code" hideLabel doctype="Item" value={item.item_code || ''} onChange={v => set('item_code', v)} /></div>
|
||||||
|
{/* schedule_date is the correct Frappe fieldname for "Required By" on MR items */}
|
||||||
|
<div><FL required>Required By</FL><input type="date" value={(item as any).schedule_date || ''} onChange={e => set('schedule_date', e.target.value)} className={editorInput} /></div>
|
||||||
|
<div><FL>Item Name</FL><input value={item.item_name || ''} onChange={e => set('item_name', e.target.value)} className={editorInput} /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RGroup label="Description">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FL>Description</FL>
|
||||||
|
<textarea value={item.description || ''} onChange={e => set('description', e.target.value)} rows={2} className={editorInput + ' resize-none'} />
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Quantity and Warehouse" defaultOpen>
|
||||||
|
<div><FL required>Quantity</FL><input type="number" min={0} step="1" value={item.qty ?? 1} onChange={e => set('qty', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||||
|
<div><FL required>UOM</FL><LinkField label="UOM" hideLabel doctype="UOM" value={item.uom || ''} onChange={v => set('uom', v)} /></div>
|
||||||
|
<div><FL>Stock UOM</FL><LinkField label="Stock UOM" hideLabel doctype="UOM" value={item.stock_uom || ''} onChange={v => set('stock_uom', v)} /></div>
|
||||||
|
<div><FL>UOM Conversion Factor</FL><input type="number" min={0} step="0.0001" value={(item as any).conversion_factor ?? 1} onChange={e => set('conversion_factor', parseFloat(e.target.value) || 1)} className={editorNum} /></div>
|
||||||
|
<div><FL>Target Warehouse</FL><LinkField label="Target Warehouse" hideLabel doctype="Warehouse" value={(item as any).warehouse || ''} onChange={v => set('warehouse', v)} /></div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Rate">
|
||||||
|
<div><FL>Rate</FL><input type="number" min={0} step="0.01" value={(item as any).rate ?? 0} onChange={e => set('rate', parseFloat(e.target.value) || 0)} className={editorNum} /></div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Accounting Details">
|
||||||
|
<div className="sm:col-span-2"><FL>Expense Account</FL><LinkField label="Expense Account" hideLabel doctype="Account" value={(item as any).expense_account || ''} onChange={v => set('expense_account', v)} /></div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Manufacturing">
|
||||||
|
<div><FL>BOM No</FL><LinkField label="BOM No" hideLabel doctype="BOM" value={(item as any).bom_no || ''} onChange={v => set('bom_no', v)} /></div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<RGroup label="Accounting Dimensions">
|
||||||
|
<div><FL>Cost Center</FL><LinkField label="Cost Center" hideLabel doctype="Cost Center" value={(item as any).cost_center || ''} onChange={v => set('cost_center', v)} /></div>
|
||||||
|
<div><FL>Project</FL><LinkField label="Project" hideLabel doctype="Project" value={(item as any).project || ''} onChange={v => set('project', v)} /></div>
|
||||||
|
</RGroup>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Empty helper ──────────────────────────────────────────────────────────────
|
||||||
|
const emptyItem = (today: string): Partial<MaterialRequestItem> => ({
|
||||||
|
item_code: '', item_name: '', qty: 1, uom: '', schedule_date: today,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
const MaterialRequestDetail: React.FC = () => {
|
||||||
|
const { mrName } = useParams<{ mrName: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = mrName === 'new';
|
||||||
|
|
||||||
|
const contextProject = searchParams.get('project') || '';
|
||||||
|
const contextCustomer = searchParams.get('customer') || '';
|
||||||
|
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||||
|
|
||||||
|
const [doc, setDoc] = useState<MaterialRequest | null>(null);
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||||
|
/** Workflow "Only Allow Edit For" — false until loaded for existing docs */
|
||||||
|
const [wfCanEdit, setWfCanEdit] = useState(true);
|
||||||
|
/** Whether Material Request has an active workflow in ERPNext (hide duplicate Submit) */
|
||||||
|
const [mrHasWorkflow, setMrHasWorkflow] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Partial<MaterialRequest>>({
|
||||||
|
material_request_type: 'Purchase',
|
||||||
|
company: contextCompany, project: contextProject, customer: contextCustomer,
|
||||||
|
transaction_date: today, schedule_date: today,
|
||||||
|
items: [emptyItem(today)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncForm = useCallback((d: MaterialRequest) => {
|
||||||
|
const items = (d.items || []).map((it: any) => ({
|
||||||
|
...it,
|
||||||
|
schedule_date: it.schedule_date || it.required_by || '',
|
||||||
|
}));
|
||||||
|
setForm({
|
||||||
|
material_request_type: d.material_request_type || 'Purchase',
|
||||||
|
company: (d as any).company || DEFAULT_COMPANY, project: (d as any).project || '', customer: (d as any).customer || '',
|
||||||
|
transaction_date: d.transaction_date || today,
|
||||||
|
schedule_date: (d as any).schedule_date || today,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}, [today]);
|
||||||
|
|
||||||
|
const fetchDoc = useCallback(() => {
|
||||||
|
if (isNew || !mrName) return;
|
||||||
|
setLoading(true);
|
||||||
|
materialRequestService.getMaterialRequest(mrName)
|
||||||
|
.then(d => { setDoc(d); syncForm(d); })
|
||||||
|
.catch(e => toast.error(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [isNew, mrName, syncForm]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchDoc(); }, [fetchDoc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
workflowService.getWorkflowInfo('Material Request').then(w => setMrHasWorkflow(!!w)).catch(() => setMrHasWorkflow(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onWorkflowMeta = useCallback((m: { canEdit: boolean }) => {
|
||||||
|
setWfCanEdit(m.canEdit);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = (k: keyof MaterialRequest, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const updateItem = (idx: number, k: string, v: any) =>
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
items[idx] = { ...items[idx], [k]: v };
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleItemCode = async (idx: number, code: string) => {
|
||||||
|
updateItem(idx, 'item_code', code);
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/resource/Item/${encodeURIComponent(code)}`, { credentials: 'include' });
|
||||||
|
const body = await r.json();
|
||||||
|
const d = body.data;
|
||||||
|
if (!d) return;
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
items[idx] = {
|
||||||
|
...items[idx], item_code: code, item_name: d.item_name || code,
|
||||||
|
stock_uom: d.stock_uom || '', uom: d.purchase_uom || d.stock_uom || '',
|
||||||
|
description: d.description || '',
|
||||||
|
};
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = (after?: number) => {
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
const pos = after !== undefined ? after + 1 : items.length;
|
||||||
|
items.splice(pos, 0, emptyItem(today));
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeItem = (idx: number) => {
|
||||||
|
setForm(f => { const items = [...(f.items || [])]; items.splice(idx, 1); return { ...f, items }; });
|
||||||
|
setExpandedItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPayload = (): Partial<MaterialRequest> => {
|
||||||
|
// Compute doc-level schedule_date from the earliest item schedule_date or today
|
||||||
|
const itemDates = (form.items || [])
|
||||||
|
.map((it: any) => it.schedule_date || it.required_by || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
const docScheduleDate = itemDates[0] || (form as any).schedule_date || today;
|
||||||
|
|
||||||
|
return {
|
||||||
|
material_request_type: form.material_request_type || 'Purchase',
|
||||||
|
company: (form as any).company || undefined,
|
||||||
|
project: (form as any).project || undefined,
|
||||||
|
customer: (form as any).customer || undefined,
|
||||||
|
transaction_date: form.transaction_date,
|
||||||
|
schedule_date: docScheduleDate, // required doc-level field
|
||||||
|
items: (form.items || []).filter(it => it.item_code).map((it: any, i) => ({
|
||||||
|
...(it.name ? { name: it.name } : {}),
|
||||||
|
item_code: it.item_code,
|
||||||
|
item_name: it.item_name || it.item_code,
|
||||||
|
description: it.description || undefined,
|
||||||
|
qty: it.qty ?? 1,
|
||||||
|
uom: it.uom || undefined,
|
||||||
|
stock_uom: it.stock_uom || undefined,
|
||||||
|
conversion_factor: it.conversion_factor ?? 1,
|
||||||
|
schedule_date: it.schedule_date || it.required_by || docScheduleDate,
|
||||||
|
warehouse: it.warehouse || undefined,
|
||||||
|
rate: it.rate ?? 0,
|
||||||
|
expense_account: it.expense_account || undefined,
|
||||||
|
cost_center: it.cost_center || (form as any).cost_center || undefined,
|
||||||
|
project: it.project || (form as any).project || undefined,
|
||||||
|
idx: i + 1,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
if (isNew) {
|
||||||
|
const created = await materialRequestService.createMaterialRequest(buildPayload());
|
||||||
|
toast.success('Material Request created');
|
||||||
|
setIsEditing(false);
|
||||||
|
navigate(`/material-requests/${created.name}`);
|
||||||
|
} else {
|
||||||
|
await materialRequestService.updateMaterialRequest(mrName!, buildPayload());
|
||||||
|
toast.success('Material Request saved');
|
||||||
|
setIsEditing(false);
|
||||||
|
fetchDoc(); // refetch to preserve all fields (company, project, etc.)
|
||||||
|
}
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Error saving'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!mrName || isNew) return;
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const updated = await materialRequestService.submitMaterialRequest(mrName);
|
||||||
|
setDoc(updated); syncForm(updated);
|
||||||
|
toast.success('Material Request submitted');
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Error submitting'); }
|
||||||
|
finally { setSubmitting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPO = () => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('mr', mrName!);
|
||||||
|
if ((form as any).company) p.set('company', String((form as any).company));
|
||||||
|
if ((form as any).project) p.set('project', String((form as any).project));
|
||||||
|
navigate(`/purchase-orders/new?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const workflowState = (doc as unknown as { workflow_state?: string })?.workflow_state || '';
|
||||||
|
const workflowDocData = useMemo(
|
||||||
|
() => (doc ? ({ ...doc } as Record<string, unknown>) : undefined),
|
||||||
|
[doc],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editable = isNew || (isEditing && wfCanEdit);
|
||||||
|
const isSubmitted = !isNew && doc?.docstatus === 1;
|
||||||
|
const showManualSubmit = !isNew && doc?.docstatus === 0 && mrHasWorkflow === false;
|
||||||
|
|
||||||
|
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-orange-500 text-3xl" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<ToastContainer position="top-right" autoClose={3500} />
|
||||||
|
|
||||||
|
{/* ── Sticky Header ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="sticky top-0 z-10 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-400 mb-2.5">
|
||||||
|
<button onClick={() => navigate('/projects')} className="hover:text-orange-500 transition-colors">Project Management</button>
|
||||||
|
<span>/</span>
|
||||||
|
<button onClick={() => navigate('/material-requests')} className="hover:text-orange-500 transition-colors">Material Requests</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-600 dark:text-gray-300 font-medium">{isNew ? 'New Material Request' : mrName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title + Actions */}
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/material-requests')} className="p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<FaArrowLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<FaBoxes className="text-orange-500 text-xl" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{isNew ? 'New Material Request' : mrName}
|
||||||
|
</h1>
|
||||||
|
{!isNew && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-0.5">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${(() => {
|
||||||
|
const s = doc?.status || '';
|
||||||
|
if (doc?.docstatus === 2 || s === 'Cancelled') return 'bg-red-100 text-red-700';
|
||||||
|
if (!doc || doc.docstatus === 0) return 'bg-yellow-100 text-yellow-800';
|
||||||
|
if (s === 'Transferred' || s === 'Issued') return 'bg-green-100 text-green-800';
|
||||||
|
if (s === 'Partially Ordered' || s === 'Ordered') return 'bg-blue-100 text-blue-800';
|
||||||
|
if (s === 'Pending') return 'bg-orange-100 text-orange-800';
|
||||||
|
if (s === 'Stopped' || s === 'Closed') return 'bg-gray-100 text-gray-700';
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
})()}`}>
|
||||||
|
{doc?.docstatus === 2 ? 'Cancelled' : doc?.docstatus === 0 ? 'Draft' : (doc?.status || 'Submitted')}
|
||||||
|
</span>
|
||||||
|
{workflowState ? (
|
||||||
|
<span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-200 border border-violet-200 dark:border-violet-700">
|
||||||
|
{workflowState}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{isSubmitted && (
|
||||||
|
<CreateDropdown items={[
|
||||||
|
{ label: 'Purchase Order', icon: <FaShoppingBag size={13} />, onClick: createPO },
|
||||||
|
]} />
|
||||||
|
)}
|
||||||
|
{showManualSubmit && !isEditing && (
|
||||||
|
<button onClick={handleSubmit} disabled={submitting}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium shadow-sm">
|
||||||
|
{submitting ? <FaSpinner className="animate-spin" size={12} /> : <FaPaperPlane size={12} />} Submit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isNew && !isEditing && !isSubmitted && wfCanEdit && (
|
||||||
|
<button onClick={() => setIsEditing(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-orange-500 text-orange-600 rounded-lg hover:bg-orange-50 text-sm font-medium">
|
||||||
|
<FaEdit size={12} /> Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 text-sm font-medium shadow-sm">
|
||||||
|
{saving ? <FaSpinner className="animate-spin" size={12} /> : <FaSave size={12} />}
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{!isNew && (
|
||||||
|
<button onClick={() => { if (doc) syncForm(doc); setIsEditing(false); }}
|
||||||
|
className="p-2 border border-gray-300 rounded-lg text-gray-500 hover:bg-gray-100 text-sm">
|
||||||
|
<FaTimes size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Page Content: main + right sidebar (matches Work Order layout) ─ */}
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
||||||
|
<div className={`space-y-3 ${!isNew ? 'lg:col-span-3' : 'lg:col-span-4'}`}>
|
||||||
|
{/* Details Card */}
|
||||||
|
<CollapsibleSection title="Details" icon={<FaBoxes size={12} className="text-orange-500" />}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||||
|
<div>
|
||||||
|
<FL required>Purpose (Type)</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={form.material_request_type || 'Purchase'} onChange={e => set('material_request_type', e.target.value)} className={inputCls}>
|
||||||
|
<option value="Purchase">Purchase</option>
|
||||||
|
<option value="Material Transfer">Material Transfer</option>
|
||||||
|
<option value="Manufacture">Manufacture</option>
|
||||||
|
<option value="Customer Provided">Customer Provided</option>
|
||||||
|
<option value="Material Issue">Material Issue</option>
|
||||||
|
</select>
|
||||||
|
: <RV>{form.material_request_type}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL required>Transaction Date</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="date" value={form.transaction_date || ''} onChange={e => set('transaction_date', e.target.value)} className={inputCls} />
|
||||||
|
: <RV>{form.transaction_date}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL>Company</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Company" hideLabel doctype="Company" value={(form as any).company || ''} onChange={v => set('company' as any, v)} placeholder="Select company…" />
|
||||||
|
: <RV>{(form as any).company}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL>Project</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Project" hideLabel doctype="Project" value={(form as any).project || ''} onChange={v => set('project' as any, v)} placeholder="Select project…" />
|
||||||
|
: <RV>{(form as any).project}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Items Card */}
|
||||||
|
<CollapsibleSection title="Items" icon={<FaBoxes size={12} className="text-amber-500" />}>
|
||||||
|
<div className="overflow-x-auto -mx-2">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Item Code <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">UOM <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Qty <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-32">Required By</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-36">Target Warehouse</th>
|
||||||
|
{editable && <th className="w-16 py-2 px-2" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(form.items || []).map((it: any, idx) => (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedItem === idx ? 'bg-amber-50/60 dark:bg-amber-900/10' : ''}`}>
|
||||||
|
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||||
|
<td className="py-1.5 px-2 min-w-[180px]">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Item" hideLabel doctype="Item" value={it.item_code || ''} onChange={v => handleItemCode(idx, v)} placeholder="Select item…" />
|
||||||
|
: <span className="font-medium text-gray-800 dark:text-gray-200">{it.item_code || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-28">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="UOM" hideLabel doctype="UOM" value={it.uom || ''} onChange={v => updateItem(idx, 'uom', v)} placeholder="UOM" />
|
||||||
|
: <span className="text-gray-500 text-sm">{it.uom || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-24">
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="1" value={it.qty ?? 1} onChange={e => updateItem(idx, 'qty', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||||
|
: <span className="block text-right text-sm pr-1">{it.qty ?? 0}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-32">
|
||||||
|
{editable
|
||||||
|
? <input type="date" value={it.schedule_date || it.required_by || ''} onChange={e => updateItem(idx, 'schedule_date', e.target.value)} className={inlineTxt} />
|
||||||
|
: <span className="text-gray-500 text-sm">{it.schedule_date || it.required_by || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-36">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Warehouse" hideLabel doctype="Warehouse" value={it.warehouse || ''} onChange={v => updateItem(idx, 'warehouse', v)} placeholder="Warehouse" />
|
||||||
|
: <span className="text-gray-500 text-sm truncate max-w-[120px] block">{it.warehouse || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
{editable && (
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setExpandedItem(expandedItem === idx ? null : idx)}
|
||||||
|
className={`p-1.5 rounded text-xs ${expandedItem === idx ? 'bg-amber-600 text-white' : 'text-amber-600 hover:bg-amber-50'}`}>
|
||||||
|
<FaPencilAlt size={11} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => removeItem(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded">
|
||||||
|
<FaTrash size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{editable && expandedItem === idx && (
|
||||||
|
<MRItemRowEditor
|
||||||
|
item={it} rowNo={idx + 1}
|
||||||
|
onChange={(k, v) => { if (k === 'item_code') handleItemCode(idx, v as string); else updateItem(idx, k, v); }}
|
||||||
|
onClose={() => setExpandedItem(null)}
|
||||||
|
onDelete={() => removeItem(idx)}
|
||||||
|
onInsertBelow={() => addItem(idx)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{editable && (
|
||||||
|
<tr><td colSpan={7} className="py-2 px-3">
|
||||||
|
<button onClick={() => addItem()} className="flex items-center gap-1.5 text-orange-600 hover:text-orange-700 text-sm font-medium">
|
||||||
|
<FaPlus size={10} /> Add Row
|
||||||
|
</button>
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isNew && mrName && (
|
||||||
|
<aside className="lg:col-span-1 space-y-6 lg:sticky lg:top-28 lg:self-start">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-5 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
Workflow Actions
|
||||||
|
</h2>
|
||||||
|
<WorkflowActions
|
||||||
|
doctype="Material Request"
|
||||||
|
docname={mrName}
|
||||||
|
workflowState={workflowState}
|
||||||
|
docData={workflowDocData as Record<string, any>}
|
||||||
|
documentLabel="Material Request"
|
||||||
|
onStateChange={fetchDoc}
|
||||||
|
onWorkflowMeta={onWorkflowMeta}
|
||||||
|
className="space-y-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Material Request"
|
||||||
|
docname={mrName || ''}
|
||||||
|
creationDate={doc?.creation}
|
||||||
|
createdBy={doc?.owner}
|
||||||
|
compact
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaterialRequestDetail;
|
||||||
230
pm_app/src/pages/MaterialRequestList.tsx
Normal file
230
pm_app/src/pages/MaterialRequestList.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaBoxes, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport } from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import materialRequestService, { MaterialRequest } from '../services/materialRequestService';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function buildMaterialRequestExportFilters(f: { search: string; status: string; type: string }) {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (f.search) filters.push(['Material Request', 'name', 'like', `%${f.search}%`]);
|
||||||
|
if (f.status === 'Draft') filters.push(['Material Request', 'docstatus', '=', 0]);
|
||||||
|
if (f.status === 'Submitted') filters.push(['Material Request', 'docstatus', '=', 1]);
|
||||||
|
if (f.status === 'Cancelled') filters.push(['Material Request', 'docstatus', '=', 2]);
|
||||||
|
if (f.type) filters.push(['Material Request', 'material_request_type', '=', f.type]);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusStyle(mr: MaterialRequest) {
|
||||||
|
if (mr.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||||
|
if (mr.docstatus === 1) return 'bg-green-100 text-green-700';
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
}
|
||||||
|
function getStatusLabel(mr: MaterialRequest) {
|
||||||
|
if (mr.docstatus === 2) return 'Cancelled';
|
||||||
|
if (mr.docstatus === 1) return mr.status || 'Submitted';
|
||||||
|
return mr.status || 'Draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaterialRequestList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [reqs, setReqs] = useState<MaterialRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
|
const [applied, setApplied] = useState({ search: '', status: '', type: '' });
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (f.search) filters.push(['Material Request', 'name', 'like', `%${f.search}%`]);
|
||||||
|
if (f.status === 'Draft') filters.push(['Material Request', 'docstatus', '=', 0]);
|
||||||
|
if (f.status === 'Submitted') filters.push(['Material Request', 'docstatus', '=', 1]);
|
||||||
|
if (f.status === 'Cancelled') filters.push(['Material Request', 'docstatus', '=', 2]);
|
||||||
|
if (f.type) filters.push(['Material Request', 'material_request_type', '=', f.type]);
|
||||||
|
const [rows, cnt] = await Promise.all([
|
||||||
|
materialRequestService.getMaterialRequests({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||||
|
materialRequestService.getMaterialRequestCount(filters),
|
||||||
|
]);
|
||||||
|
setReqs(rows); setTotal(cnt);
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Failed to load'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${page}|${applied.search}|${applied.status}|${applied.type}`,
|
||||||
|
[page, applied.search, applied.status, applied.type],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(reqs, selectionResetKey);
|
||||||
|
|
||||||
|
const apply = () => { const f = { search: searchQuery, status: statusFilter, type: typeFilter }; setApplied(f); setPage(0); };
|
||||||
|
const clear = () => { setSearchQuery(''); setStatusFilter(''); setTypeFilter(''); setApplied({ search: '', status: '', type: '' }); setPage(0); };
|
||||||
|
const hasActive = !!(applied.search || applied.status || applied.type);
|
||||||
|
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() =>
|
||||||
|
fetchAllRowsForExport({
|
||||||
|
doctype: 'Material Request',
|
||||||
|
filters: buildMaterialRequestExportFilters(applied),
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
}),
|
||||||
|
[applied],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-orange-500 flex items-center justify-center"><FaBoxes className="text-white text-base" /></div>
|
||||||
|
<div><h1 className="text-xl font-bold text-gray-900 dark:text-white">Material Requests</h1><p className="text-xs text-gray-500">{total} total</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-indigo-600 border border-gray-200 rounded-lg"><FaSync size={13} /></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={total === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/material-requests/new')} className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 text-sm font-medium"><FaPlus size={11} /> New Request</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Material Request"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={reqs.length}
|
||||||
|
totalCount={total}
|
||||||
|
pageData={reqs}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="material_requests"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||||
|
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-indigo-600 to-indigo-700 text-white">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold"><FaSearch size={12} /> Filters {hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}</div>
|
||||||
|
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||||
|
</button>
|
||||||
|
{hasActive && (
|
||||||
|
<div className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/20 flex flex-wrap gap-2 items-center border-b border-indigo-100">
|
||||||
|
{applied.search && <span className="flex items-center gap-1 text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full">ID: {applied.search}<button onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
{applied.status && <span className="flex items-center gap-1 text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full">Status: {applied.status}<button onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
{applied.type && <span className="flex items-center gap-1 text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full">Type: {applied.type}<button onClick={() => { setTypeFilter(''); setApplied(a => ({ ...a, type: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
<button onClick={clear} className="text-xs text-indigo-600 hover:underline ml-auto">Clear All</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||||
|
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Request ID</label>
|
||||||
|
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && apply()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400" /></div>
|
||||||
|
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Type</label>
|
||||||
|
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||||
|
<option value="">All</option><option value="Purchase">Purchase</option><option value="Material Transfer">Material Transfer</option><option value="Manufacture">Manufacture</option>
|
||||||
|
</select></div>
|
||||||
|
<div><label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||||
|
<option value="">All</option><option value="Draft">Draft</option><option value="Submitted">Submitted</option><option value="Cancelled">Cancelled</option>
|
||||||
|
</select></div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<button onClick={apply} className="px-4 py-2 bg-indigo-600 text-white text-sm rounded hover:bg-indigo-700">Apply</button>
|
||||||
|
<button onClick={clear} className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded hover:bg-gray-50">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="w-10 px-2 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500"
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
ref={el => {
|
||||||
|
if (el) el.indeterminate = someOnPageSelected;
|
||||||
|
}}
|
||||||
|
onChange={toggleAllOnPage}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Request ID</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Type</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Company</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{loading ? <tr><td colSpan={6} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||||
|
: reqs.length === 0 ? <tr><td colSpan={6} className="text-center py-10 text-gray-400">No material requests found</td></tr>
|
||||||
|
: reqs.map(mr => (
|
||||||
|
<tr key={mr.name} onClick={() => navigate(`/material-requests/${mr.name}`)} className={`cursor-pointer hover:bg-orange-50 dark:hover:bg-orange-900/10 transition-colors ${selectedRows.has(mr.name) ? 'bg-orange-50/90 dark:bg-orange-900/20' : ''}`}>
|
||||||
|
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500"
|
||||||
|
checked={selectedRows.has(mr.name)}
|
||||||
|
onChange={() => toggleRow(mr.name)}
|
||||||
|
aria-label={`Select ${mr.name}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-medium text-orange-600">{mr.name}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{mr.material_request_type || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{mr.transaction_date || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{mr.company || '-'}</td>
|
||||||
|
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(mr)}`}>{getStatusLabel(mr)}</span></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||||
|
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaterialRequestList;
|
||||||
561
pm_app/src/pages/PaymentEntryDetail.tsx
Normal file
561
pm_app/src/pages/PaymentEntryDetail.tsx
Normal file
@ -0,0 +1,561 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { FaMoneyBillWave, FaArrowLeft, FaSave, FaCheck, FaPlus, FaTrash } from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import { paymentEntryService, PaymentEntry, PaymentEntryReference } from '../services/paymentEntryService';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
import { DEFAULT_COMPANY, DEFAULT_CURRENCY } from '../constants/orgDefaults';
|
||||||
|
|
||||||
|
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
const RV: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<p className="text-sm text-gray-800 dark:text-gray-200 min-h-[20px] py-0.5">{children || <span className="text-gray-400 italic">—</span>}</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputCls = 'w-full border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-teal-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100';
|
||||||
|
const numCls = inputCls + ' text-right';
|
||||||
|
|
||||||
|
const statusBadge = (pe: Partial<PaymentEntry>) => {
|
||||||
|
const ds = pe.docstatus ?? 0;
|
||||||
|
if (ds === 2) return <span className="px-2 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700">Cancelled</span>;
|
||||||
|
if (ds === 1) {
|
||||||
|
const s = pe.status || 'Submitted';
|
||||||
|
if (s === 'Paid') return <span className="px-2 py-1 rounded-full text-xs font-bold bg-green-100 text-green-700">Paid</span>;
|
||||||
|
return <span className="px-2 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">{s}</span>;
|
||||||
|
}
|
||||||
|
return <span className="px-2 py-1 rounded-full text-xs font-bold bg-yellow-100 text-yellow-700">Draft</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="px-4 py-2.5 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<h3 className="text-xs font-bold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyRef = (): Partial<PaymentEntryReference> => ({
|
||||||
|
reference_doctype: 'Sales Invoice',
|
||||||
|
reference_name: '',
|
||||||
|
total_amount: 0,
|
||||||
|
outstanding_amount: 0,
|
||||||
|
allocated_amount: 0,
|
||||||
|
exchange_rate: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Default cash/bank account for Receive payments (company-specific; change if your COA differs). */
|
||||||
|
const DEFAULT_ACCOUNT_PAID_TO = 'Cash - SA';
|
||||||
|
|
||||||
|
export default function PaymentEntryDetail() {
|
||||||
|
const { peName } = useParams<{ peName: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const isNew = peName === 'new';
|
||||||
|
|
||||||
|
const contextSI = searchParams.get('si') || '';
|
||||||
|
const contextCustomer = searchParams.get('customer') || '';
|
||||||
|
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||||
|
const contextProject = searchParams.get('project') || '';
|
||||||
|
const contextAmount = parseFloat(searchParams.get('amount') || '0');
|
||||||
|
const contextCurrency = searchParams.get('currency') || DEFAULT_CURRENCY;
|
||||||
|
|
||||||
|
const [doc, setDoc] = useState<PaymentEntry | null>(null);
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Partial<PaymentEntry>>({
|
||||||
|
payment_type: 'Receive',
|
||||||
|
posting_date: today,
|
||||||
|
company: contextCompany,
|
||||||
|
party_type: 'Customer',
|
||||||
|
party: contextCustomer || '',
|
||||||
|
party_name: contextCustomer || '',
|
||||||
|
mode_of_payment: 'Cash',
|
||||||
|
paid_to: DEFAULT_ACCOUNT_PAID_TO,
|
||||||
|
reference_date: today,
|
||||||
|
reference_no: '',
|
||||||
|
paid_amount: contextAmount || 0,
|
||||||
|
received_amount: contextAmount || 0,
|
||||||
|
source_exchange_rate: 1,
|
||||||
|
target_exchange_rate: 1,
|
||||||
|
project: contextProject || '',
|
||||||
|
references: contextSI ? [{
|
||||||
|
reference_doctype: 'Sales Invoice',
|
||||||
|
reference_name: contextSI,
|
||||||
|
total_amount: contextAmount,
|
||||||
|
outstanding_amount: contextAmount,
|
||||||
|
allocated_amount: contextAmount,
|
||||||
|
exchange_rate: 1,
|
||||||
|
}] : [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncForm = useCallback((d: PaymentEntry) => {
|
||||||
|
setForm({
|
||||||
|
payment_type: d.payment_type || 'Receive',
|
||||||
|
posting_date: d.posting_date || today,
|
||||||
|
company: d.company || DEFAULT_COMPANY,
|
||||||
|
party_type: d.party_type || 'Customer',
|
||||||
|
party: d.party || '',
|
||||||
|
party_name: d.party_name || '',
|
||||||
|
mode_of_payment: d.mode_of_payment || '',
|
||||||
|
paid_from: d.paid_from || '',
|
||||||
|
paid_to: d.paid_to || '',
|
||||||
|
paid_from_account_currency: d.paid_from_account_currency || '',
|
||||||
|
paid_to_account_currency: d.paid_to_account_currency || '',
|
||||||
|
paid_amount: d.paid_amount || 0,
|
||||||
|
received_amount: d.received_amount || 0,
|
||||||
|
source_exchange_rate: d.source_exchange_rate || 1,
|
||||||
|
target_exchange_rate: d.target_exchange_rate || 1,
|
||||||
|
total_allocated_amount: d.total_allocated_amount || 0,
|
||||||
|
unallocated_amount: d.unallocated_amount || 0,
|
||||||
|
difference_amount: d.difference_amount || 0,
|
||||||
|
project: d.project || '',
|
||||||
|
cost_center: d.cost_center || '',
|
||||||
|
remarks: d.remarks || '',
|
||||||
|
reference_no: (d as any).reference_no || '',
|
||||||
|
reference_date: (d as any).reference_date || '',
|
||||||
|
references: d.references || [],
|
||||||
|
} as any);
|
||||||
|
}, [today]);
|
||||||
|
|
||||||
|
// Pre-fill from SI
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew || !contextSI) return;
|
||||||
|
fetch(`/api/resource/Sales Invoice/${encodeURIComponent(contextSI)}`, { credentials: 'include' })
|
||||||
|
.then(r => r.json()).then(b => {
|
||||||
|
const si = b.data;
|
||||||
|
if (!si) return;
|
||||||
|
const outstanding = si.outstanding_amount || si.grand_total || 0;
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
party: si.customer || f.party,
|
||||||
|
party_name: si.customer_name || si.customer || f.party_name,
|
||||||
|
company: si.company || f.company,
|
||||||
|
project: si.project || f.project,
|
||||||
|
paid_from: si.debit_to || '',
|
||||||
|
paid_from_account_currency: si.currency || contextCurrency || '',
|
||||||
|
paid_to_account_currency: si.currency || contextCurrency || '',
|
||||||
|
paid_amount: outstanding,
|
||||||
|
received_amount: outstanding,
|
||||||
|
references: [{
|
||||||
|
reference_doctype: 'Sales Invoice',
|
||||||
|
reference_name: contextSI,
|
||||||
|
total_amount: si.grand_total || 0,
|
||||||
|
outstanding_amount: outstanding,
|
||||||
|
allocated_amount: outstanding,
|
||||||
|
exchange_rate: 1,
|
||||||
|
}],
|
||||||
|
remarks: `Amount ${si.currency || ''} ${outstanding} received from ${si.customer_name || si.customer}\nAmount ${si.currency || ''} ${outstanding} against Sales Invoice ${contextSI}`,
|
||||||
|
} as any));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [isNew, contextSI]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return;
|
||||||
|
setLoading(true);
|
||||||
|
paymentEntryService.getPaymentEntry(peName!)
|
||||||
|
.then(d => { setDoc(d); syncForm(d); })
|
||||||
|
.catch(e => toast.error(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [peName, isNew, syncForm]);
|
||||||
|
|
||||||
|
const set = (k: string, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
// References helpers
|
||||||
|
const updateRef = (idx: number, k: string, v: any) => {
|
||||||
|
setForm(f => {
|
||||||
|
const refs = [...(f.references || [])];
|
||||||
|
refs[idx] = { ...refs[idx], [k]: v };
|
||||||
|
// auto-set allocated = outstanding if not changed
|
||||||
|
if (k === 'outstanding_amount') refs[idx].allocated_amount = v;
|
||||||
|
return { ...f, references: refs };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRef = () => setForm(f => ({ ...f, references: [...(f.references || []), emptyRef()] }));
|
||||||
|
const removeRef = (idx: number) => setForm(f => ({ ...f, references: (f.references || []).filter((_, i) => i !== idx) }));
|
||||||
|
|
||||||
|
// Auto-update paid_amount from allocated refs
|
||||||
|
const totalAllocated = (form.references || []).reduce((s, r) => s + (r.allocated_amount || 0), 0);
|
||||||
|
|
||||||
|
// Fetch reference outstanding amount when ref name is set
|
||||||
|
const fetchRefDetails = async (idx: number, refDoctype: string, refName: string) => {
|
||||||
|
if (!refDoctype || !refName) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/resource/${encodeURIComponent(refDoctype)}/${encodeURIComponent(refName)}`, { credentials: 'include' });
|
||||||
|
const b = await r.json();
|
||||||
|
const d = b.data;
|
||||||
|
if (!d) return;
|
||||||
|
setForm(f => {
|
||||||
|
const refs = [...(f.references || [])];
|
||||||
|
refs[idx] = {
|
||||||
|
...refs[idx],
|
||||||
|
total_amount: d.grand_total || d.outstanding_amount || 0,
|
||||||
|
outstanding_amount: d.outstanding_amount || d.grand_total || 0,
|
||||||
|
allocated_amount: d.outstanding_amount || d.grand_total || 0,
|
||||||
|
};
|
||||||
|
return { ...f, references: refs, paid_amount: d.outstanding_amount || d.grand_total || 0, received_amount: d.outstanding_amount || d.grand_total || 0 };
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPayload = (): Partial<PaymentEntry> => ({
|
||||||
|
payment_type: form.payment_type || 'Receive',
|
||||||
|
posting_date: form.posting_date,
|
||||||
|
company: (form as any).company || undefined,
|
||||||
|
party_type: form.party_type || 'Customer',
|
||||||
|
party: form.party,
|
||||||
|
party_name: form.party_name || form.party,
|
||||||
|
mode_of_payment: (form as any).mode_of_payment || undefined,
|
||||||
|
paid_from: (form as any).paid_from || undefined,
|
||||||
|
paid_to: (form as any).paid_to || undefined,
|
||||||
|
paid_from_account_currency: (form as any).paid_from_account_currency || undefined,
|
||||||
|
paid_to_account_currency: (form as any).paid_to_account_currency || undefined,
|
||||||
|
paid_amount: form.paid_amount || totalAllocated,
|
||||||
|
received_amount: form.received_amount || totalAllocated,
|
||||||
|
source_exchange_rate: (form as any).source_exchange_rate || 1,
|
||||||
|
target_exchange_rate: (form as any).target_exchange_rate || 1,
|
||||||
|
project: (form as any).project || undefined,
|
||||||
|
cost_center: (form as any).cost_center || undefined,
|
||||||
|
remarks: (form as any).remarks || undefined,
|
||||||
|
reference_no: (form as any).reference_no || undefined,
|
||||||
|
reference_date: (form as any).reference_date || undefined,
|
||||||
|
references: (form.references || []).filter(r => r.reference_name).map((r, i) => ({
|
||||||
|
reference_doctype: r.reference_doctype || 'Sales Invoice',
|
||||||
|
reference_name: r.reference_name,
|
||||||
|
total_amount: r.total_amount || 0,
|
||||||
|
outstanding_amount: r.outstanding_amount || 0,
|
||||||
|
allocated_amount: r.allocated_amount || 0,
|
||||||
|
exchange_rate: r.exchange_rate || 1,
|
||||||
|
idx: i + 1,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.party) { toast.error('Party is required'); return; }
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
if (isNew) {
|
||||||
|
const created = await paymentEntryService.createPaymentEntry(buildPayload());
|
||||||
|
toast.success('Payment Entry created');
|
||||||
|
setIsEditing(false);
|
||||||
|
navigate(`/payment-entries/${created.name}`);
|
||||||
|
} else {
|
||||||
|
const updated = await paymentEntryService.updatePaymentEntry(peName!, buildPayload());
|
||||||
|
setDoc(updated); syncForm(updated);
|
||||||
|
toast.success('Payment Entry saved');
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Error saving'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!peName || isNew) return;
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const updated = await paymentEntryService.submitPaymentEntry(peName);
|
||||||
|
setDoc(updated); syncForm(updated);
|
||||||
|
toast.success('Payment Entry submitted');
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Error submitting'); }
|
||||||
|
finally { setSubmitting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const editable = isEditing && (doc?.docstatus ?? 0) < 1;
|
||||||
|
const isSubmitted = (doc?.docstatus ?? 0) === 1;
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<><ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6 space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/payment-entries')} className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
<FaArrowLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-teal-600 flex items-center justify-center shadow-sm">
|
||||||
|
<FaMoneyBillWave className="text-white" size={15} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{isNew ? 'New Payment Entry' : (doc?.party_name || doc?.party || peName)}
|
||||||
|
</h1>
|
||||||
|
{!isNew && statusBadge(doc || {})}
|
||||||
|
</div>
|
||||||
|
{!isNew && <p className="text-xs text-gray-400">{peName}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editable && (
|
||||||
|
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white text-sm font-semibold rounded-lg shadow disabled:opacity-50 transition-colors">
|
||||||
|
<FaSave size={13} />{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isNew && !isEditing && !isSubmitted && (
|
||||||
|
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-teal-500 text-teal-600 hover:bg-teal-50 text-sm font-semibold rounded-lg transition-colors">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isNew && !isSubmitted && !isEditing && (
|
||||||
|
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg shadow disabled:opacity-50 transition-colors">
|
||||||
|
<FaCheck size={12} />{submitting ? 'Submitting…' : 'Submit'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Type & Date */}
|
||||||
|
<Section title="Payment Details">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<FL required>Payment Type</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={(form as any).payment_type || 'Receive'} onChange={e => set('payment_type', e.target.value)} className={inputCls}>
|
||||||
|
<option value="Receive">Receive</option>
|
||||||
|
<option value="Pay">Pay</option>
|
||||||
|
<option value="Internal Transfer">Internal Transfer</option>
|
||||||
|
</select>
|
||||||
|
: <RV>{(form as any).payment_type}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL required>Posting Date</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="date" value={(form as any).posting_date || ''} onChange={e => set('posting_date', e.target.value)} className={inputCls} />
|
||||||
|
: <RV>{(form as any).posting_date}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL>Mode of Payment</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Mode of Payment" hideLabel doctype="Mode of Payment" value={(form as any).mode_of_payment || ''} onChange={v => set('mode_of_payment', v)} placeholder="Cash, Bank…" />
|
||||||
|
: <RV>{(form as any).mode_of_payment}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL>Company</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Company" hideLabel doctype="Company" value={(form as any).company || ''} onChange={v => set('company', v)} placeholder="Company…" />
|
||||||
|
: <RV>{(form as any).company}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Party */}
|
||||||
|
<Section title="Payment From / To">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<FL>Party Type</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={(form as any).party_type || 'Customer'} onChange={e => set('party_type', e.target.value)} className={inputCls}>
|
||||||
|
<option value="Customer">Customer</option>
|
||||||
|
<option value="Supplier">Supplier</option>
|
||||||
|
<option value="Employee">Employee</option>
|
||||||
|
</select>
|
||||||
|
: <RV>{(form as any).party_type}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL required>Party</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Party" hideLabel doctype={(form as any).party_type || 'Customer'} value={(form as any).party || ''} onChange={v => { set('party', v); set('party_name', v); }} placeholder="Select party…" />
|
||||||
|
: <RV>{(form as any).party_name || (form as any).party}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL>Project</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Project" hideLabel doctype="Project" value={(form as any).project || ''} onChange={v => set('project', v)} placeholder="Project…" />
|
||||||
|
: <RV>{(form as any).project}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL>Cost Center</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Cost Center" hideLabel doctype="Cost Center" value={(form as any).cost_center || ''} onChange={v => set('cost_center', v)} placeholder="Cost center…" />
|
||||||
|
: <RV>{(form as any).cost_center}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Accounts */}
|
||||||
|
<Section title="Accounts">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<FL>Account Paid From</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Account Paid From" hideLabel doctype="Account" value={(form as any).paid_from || ''} onChange={v => set('paid_from', v)} placeholder="Debtors account…" />
|
||||||
|
: <RV>{(form as any).paid_from}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL>Account Paid To</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Account Paid To" hideLabel doctype="Account" value={(form as any).paid_to || ''} onChange={v => set('paid_to', v)} placeholder="Cash / Bank account…" />
|
||||||
|
: <RV>{(form as any).paid_to}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL required>Paid Amount</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="0.01" value={(form as any).paid_amount ?? 0} onChange={e => { const v = parseFloat(e.target.value) || 0; set('paid_amount', v); set('received_amount', v); }} className={numCls} />
|
||||||
|
: <RV>{((form as any).paid_amount || 0).toFixed(2)}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL>Received Amount</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="0.01" value={(form as any).received_amount ?? 0} onChange={e => set('received_amount', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
: <RV>{((form as any).received_amount || 0).toFixed(2)}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Transaction ID — ERPNext: reference_no, reference_date */}
|
||||||
|
<Section title="Transaction ID">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<FL>Cheque / Reference No</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="text" value={(form as any).reference_no || ''} onChange={e => set('reference_no', e.target.value)} className={inputCls} placeholder="Reference number…" />
|
||||||
|
: <RV>{(form as any).reference_no}</RV>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FL>Cheque / Reference Date</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="date" value={(form as any).reference_date || ''} onChange={e => set('reference_date', e.target.value)} className={inputCls} />
|
||||||
|
: <RV>{(form as any).reference_date}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* References */}
|
||||||
|
<Section title="Payment References">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3">Type</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3">Reference</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Total Amount</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Outstanding</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Allocated</th>
|
||||||
|
{editable && <th className="w-10 py-2 px-2" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(form.references || []).map((ref, idx) => (
|
||||||
|
<tr key={idx} className="border-b border-gray-100 dark:border-gray-700 align-middle">
|
||||||
|
<td className="py-2 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||||
|
<td className="py-2 px-3 w-40">
|
||||||
|
{editable
|
||||||
|
? <select value={ref.reference_doctype || ''} onChange={e => updateRef(idx, 'reference_doctype', e.target.value)} className="w-full border border-gray-200 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100">
|
||||||
|
<option value="Sales Invoice">Sales Invoice</option>
|
||||||
|
<option value="Purchase Invoice">Purchase Invoice</option>
|
||||||
|
<option value="Sales Order">Sales Order</option>
|
||||||
|
<option value="Purchase Order">Purchase Order</option>
|
||||||
|
<option value="Journal Entry">Journal Entry</option>
|
||||||
|
</select>
|
||||||
|
: <span className="text-gray-700 dark:text-gray-300 text-xs">{ref.reference_doctype}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[180px]">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Reference" hideLabel
|
||||||
|
doctype={ref.reference_doctype || 'Sales Invoice'}
|
||||||
|
value={ref.reference_name || ''}
|
||||||
|
onChange={v => { updateRef(idx, 'reference_name', v); if (v) fetchRefDetails(idx, ref.reference_doctype || 'Sales Invoice', v); }}
|
||||||
|
placeholder="Select document…"
|
||||||
|
/>
|
||||||
|
: <span className="text-teal-600 font-medium text-xs">{ref.reference_name || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-right text-gray-700 dark:text-gray-300 text-xs">
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="0.01" value={ref.total_amount ?? 0} onChange={e => updateRef(idx, 'total_amount', parseFloat(e.target.value) || 0)} className="w-full border border-gray-200 rounded px-2 py-1 text-xs text-right bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100" />
|
||||||
|
: (ref.total_amount || 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-right text-gray-700 dark:text-gray-300 text-xs">
|
||||||
|
{(ref.outstanding_amount || 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-right font-semibold text-gray-900 dark:text-white text-xs">
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="0.01" value={ref.allocated_amount ?? 0} onChange={e => updateRef(idx, 'allocated_amount', parseFloat(e.target.value) || 0)} className="w-full border border-gray-200 rounded px-2 py-1 text-xs text-right bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100" />
|
||||||
|
: (ref.allocated_amount || 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
{editable && (
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<button onClick={() => removeRef(idx)} className="p-1 text-red-400 hover:text-red-600 rounded"><FaTrash size={11} /></button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{editable && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-2 px-3">
|
||||||
|
<button onClick={addRef} className="flex items-center gap-1.5 text-teal-600 hover:text-teal-700 text-sm font-medium">
|
||||||
|
<FaPlus size={10} /> Add Row
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 gap-6 text-sm">
|
||||||
|
<span className="text-gray-500">Total Allocated: <strong className="text-gray-900 dark:text-white">{totalAllocated.toFixed(2)}</strong></span>
|
||||||
|
<span className="text-gray-500">Unallocated: <strong className="text-gray-900 dark:text-white">{Math.max(0, ((form as any).paid_amount || 0) - totalAllocated).toFixed(2)}</strong></span>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Remarks */}
|
||||||
|
<Section title="Remarks">
|
||||||
|
<div>
|
||||||
|
{editable
|
||||||
|
? <textarea rows={3} value={(form as any).remarks || ''} onChange={e => set('remarks', e.target.value)} className={inputCls} placeholder="Remarks…" />
|
||||||
|
: <RV>{(form as any).remarks}</RV>}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
{!isNew && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Paid Amount', value: ((form as any).paid_amount || 0).toFixed(2) },
|
||||||
|
{ label: 'Total Allocated', value: totalAllocated.toFixed(2) },
|
||||||
|
{ label: 'Unallocated', value: Math.max(0, ((form as any).paid_amount || 0) - totalAllocated).toFixed(2) },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="text-center">
|
||||||
|
<p className="text-[10px] font-semibold text-gray-500 uppercase mb-1">{label}</p>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && doc && (
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Payment Entry"
|
||||||
|
docname={doc.name || peName || ''}
|
||||||
|
creationDate={doc.creation}
|
||||||
|
createdBy={doc.owner}
|
||||||
|
compact={false}
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
pm_app/src/pages/PaymentEntryList.tsx
Normal file
267
pm_app/src/pages/PaymentEntryList.tsx
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaMoneyBillWave, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaFilter, FaTimes, FaFileExport, FaEye, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import { paymentEntryService } from '../services/paymentEntryService';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const statusStyle = (status: string, docstatus: number) => {
|
||||||
|
if (docstatus === 1) {
|
||||||
|
if (status === 'Paid') return 'bg-green-100 text-green-700';
|
||||||
|
if (status === 'Submitted') return 'bg-blue-100 text-blue-700';
|
||||||
|
return 'bg-blue-100 text-blue-700';
|
||||||
|
}
|
||||||
|
if (docstatus === 2) return 'bg-red-100 text-red-700';
|
||||||
|
return 'bg-yellow-100 text-yellow-700';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = (status: string, docstatus: number) => {
|
||||||
|
if (docstatus === 2) return 'Cancelled';
|
||||||
|
if (docstatus === 1) return status || 'Submitted';
|
||||||
|
return 'Draft';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PaymentEntryList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [entries, setEntries] = useState<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [partyFilter, setPartyFilter] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
|
||||||
|
const buildFilters = useCallback(() => {
|
||||||
|
const f: any[] = [];
|
||||||
|
if (searchQuery) f.push(['name', 'like', `%${searchQuery}%`]);
|
||||||
|
if (partyFilter) f.push(['party', 'like', `%${partyFilter}%`]);
|
||||||
|
if (typeFilter) f.push(['payment_type', '=', typeFilter]);
|
||||||
|
return f;
|
||||||
|
}, [searchQuery, partyFilter, typeFilter]);
|
||||||
|
|
||||||
|
const load = useCallback(async (pg = 0) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters = buildFilters();
|
||||||
|
const [data, cnt] = await Promise.all([
|
||||||
|
paymentEntryService.getPaymentEntries({ filters, limit_start: pg * PAGE_SIZE, limit_page_length: PAGE_SIZE }),
|
||||||
|
paymentEntryService.getPaymentEntryCount(filters),
|
||||||
|
]);
|
||||||
|
setEntries(data); setTotal(cnt); setPage(pg);
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Error loading'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, [buildFilters]);
|
||||||
|
|
||||||
|
useEffect(() => { load(0); }, [load]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${page}|${searchQuery}|${partyFilter}|${typeFilter}`,
|
||||||
|
[page, searchQuery, partyFilter, typeFilter],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(entries, selectionResetKey);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() => fetchAllRowsForExport({ doctype: 'Payment Entry', filters: buildFilters(), orderBy: 'modified desc' }),
|
||||||
|
[buildFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goPage = (pg: number) => load(pg);
|
||||||
|
|
||||||
|
const activeFilterCount = [searchQuery, partyFilter, typeFilter].filter(Boolean).length;
|
||||||
|
|
||||||
|
const clearFilters = () => { setSearchQuery(''); setPartyFilter(''); setTypeFilter(''); };
|
||||||
|
const handleView = (name: string) => navigate(`/payment-entries/${encodeURIComponent(name)}`);
|
||||||
|
const handleEdit = (name: string) => navigate(`/payment-entries/${encodeURIComponent(name)}?edit=1`);
|
||||||
|
const handleDuplicate = (name: string) => navigate(`/payment-entries/new?duplicate=${encodeURIComponent(name)}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<><ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
<div className="px-6 py-6 space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center shadow">
|
||||||
|
<FaMoneyBillWave className="text-white" size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Payment Entries</h1>
|
||||||
|
<p className="text-xs text-gray-500">{total} total</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button onClick={() => load(page)} className="p-2 text-gray-500 hover:text-teal-600 hover:bg-teal-50 rounded-lg transition-colors"><FaSync size={14} /></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={total === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/payment-entries/new')} className="flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white text-sm font-semibold rounded-lg shadow transition-colors">
|
||||||
|
<FaPlus size={12} /> New Entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Payment Entry"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={entries.length}
|
||||||
|
totalCount={total}
|
||||||
|
pageData={entries}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="payment_entries"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="rounded-xl border border-blue-200 dark:border-blue-800 overflow-hidden shadow-sm">
|
||||||
|
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 text-white text-sm font-semibold">
|
||||||
|
<span className="flex items-center gap-2"><FaFilter size={12} /> Filters {activeFilterCount > 0 && <span className="bg-white/25 text-white text-[10px] px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}</span>
|
||||||
|
{filtersOpen ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||||
|
</button>
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="p-4 bg-white dark:bg-gray-800 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Payment ID</label>
|
||||||
|
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search by ID…" className="w-full border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Party</label>
|
||||||
|
<input value={partyFilter} onChange={e => setPartyFilter(e.target.value)} placeholder="Customer / Supplier…" className="w-full border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Type</label>
|
||||||
|
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} className="w-full border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="Receive">Receive</option>
|
||||||
|
<option value="Pay">Pay</option>
|
||||||
|
<option value="Internal Transfer">Internal Transfer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<div className="sm:col-span-3 flex flex-wrap gap-2 items-center">
|
||||||
|
{searchQuery && <span className="flex items-center gap-1 bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">ID: {searchQuery}<button onClick={() => setSearchQuery('')}><FaTimes size={9} /></button></span>}
|
||||||
|
{partyFilter && <span className="flex items-center gap-1 bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">Party: {partyFilter}<button onClick={() => setPartyFilter('')}><FaTimes size={9} /></button></span>}
|
||||||
|
{typeFilter && <span className="flex items-center gap-1 bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">Type: {typeFilter}<button onClick={() => setTypeFilter('')}><FaTimes size={9} /></button></span>}
|
||||||
|
<button onClick={clearFilters} className="text-xs text-blue-600 hover:text-blue-800 font-medium ml-1">Clear all</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="w-10 px-4 py-3 text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllOnPage}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors"
|
||||||
|
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
>
|
||||||
|
{allOnPageSelected
|
||||||
|
? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} />
|
||||||
|
: someOnPageSelected
|
||||||
|
? (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<FaSquare size={18} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-2 h-0.5 bg-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Payment ID</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Type</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Party</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Amount</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-28"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{loading
|
||||||
|
? <tr><td colSpan={8} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||||
|
: entries.length === 0
|
||||||
|
? <tr><td colSpan={8} className="text-center py-10 text-gray-400">No payment entries found</td></tr>
|
||||||
|
: entries.map(pe => (
|
||||||
|
<tr key={pe.name} onClick={() => handleView(pe.name)} className={`cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${selectedRows.has(pe.name) ? 'bg-teal-50 dark:bg-teal-900/20' : ''}`}>
|
||||||
|
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleRow(pe.name)}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors"
|
||||||
|
aria-label={`Select ${pe.name}`}
|
||||||
|
>
|
||||||
|
{selectedRows.has(pe.name)
|
||||||
|
? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} />
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-medium text-gray-900 dark:text-white">{pe.name}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{pe.payment_type || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{pe.posting_date || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{pe.party_name || pe.party || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">{(pe.paid_amount || 0).toFixed(2)}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${statusStyle(pe.status, pe.docstatus)}`}>
|
||||||
|
{statusLabel(pe.status, pe.docstatus)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-4" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => handleView(pe.name)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title="View"><FaEye /></button>
|
||||||
|
<button onClick={() => handleEdit(pe.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit"><FaEdit /></button>
|
||||||
|
<button onClick={() => handleDuplicate(pe.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate"><FaCopy /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||||
|
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1149
pm_app/src/pages/ProjectDetail.tsx
Normal file
1149
pm_app/src/pages/ProjectDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
608
pm_app/src/pages/ProjectList.tsx
Normal file
608
pm_app/src/pages/ProjectList.tsx
Normal file
@ -0,0 +1,608 @@
|
|||||||
|
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useProjectList } from '../hooks/useProject';
|
||||||
|
import ListPagination from '../components/ListPagination';
|
||||||
|
import { FaSearch, FaFilter, FaChevronDown, FaChevronUp, FaSync, FaEye, FaPlus, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare, FaMicrophone } from 'react-icons/fa';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
import { buildDateRangeFilters } from '../utils/listFilterUtils';
|
||||||
|
import type { Project } from '../services/projectService';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import VoiceStatusModal, { PROJECT_STATUS_OPTIONS } from '../components/VoiceStatusModal';
|
||||||
|
|
||||||
|
const getStatusStyle = (status: string) => {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case 'open':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityStyle = (priority: string) => {
|
||||||
|
switch (priority?.toLowerCase()) {
|
||||||
|
case 'high':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
|
||||||
|
case 'low':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const setSearchParamsRef = useRef(setSearchParams);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchParamsRef.current = setSearchParams;
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
|
const currentPage = useMemo(() => {
|
||||||
|
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const setCurrentPage = useCallback(
|
||||||
|
(pageOrUpdater: number | ((p: number) => number)) => {
|
||||||
|
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const nextParams = new URLSearchParams(prev);
|
||||||
|
nextParams.set('page', String(next));
|
||||||
|
return nextParams;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[currentPage, setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState<string>(() => searchParams.get('priority') || '');
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>(() => searchParams.get('q') || '');
|
||||||
|
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(
|
||||||
|
() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || ''
|
||||||
|
);
|
||||||
|
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
|
||||||
|
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
|
||||||
|
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'modified desc');
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
|
||||||
|
// ── Voice Command Assist ──────────────────────────────────────────
|
||||||
|
const [showVoiceModal, setShowVoiceModal] = useState(false);
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const didInitUrlSync = useRef(false);
|
||||||
|
const skipInitialSearchUrlSync = useRef(true);
|
||||||
|
const searchDebounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const filters: Record<string, any> = {};
|
||||||
|
if (statusFilter) filters['status'] = statusFilter;
|
||||||
|
if (priorityFilter) filters['priority'] = priorityFilter;
|
||||||
|
if (searchQuery) filters['project_name'] = ['like', `%${searchQuery}%`];
|
||||||
|
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||||
|
return filters;
|
||||||
|
}, [statusFilter, priorityFilter, searchQuery, dateFilterBy, dateStart, dateEnd]);
|
||||||
|
|
||||||
|
const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc'].includes(sortBy) ? sortBy : 'modified desc';
|
||||||
|
|
||||||
|
const { projects, loading, error, totalCount, refetch } = useProjectList({
|
||||||
|
filters: apiFilters,
|
||||||
|
limit_start: (currentPage - 1) * pageSize,
|
||||||
|
limit_page_length: pageSize,
|
||||||
|
order_by: orderBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${currentPage}|${sortBy}|${JSON.stringify(apiFilters)}`,
|
||||||
|
[currentPage, sortBy, apiFilters],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(projects, selectionResetKey);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() => fetchAllRowsForExport({ doctype: 'Project', filters: apiFilters, orderBy }),
|
||||||
|
[apiFilters, orderBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
const formatDate = (dateStr: string) =>
|
||||||
|
dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setStatusFilter('');
|
||||||
|
setPriorityFilter('');
|
||||||
|
setSearchQuery('');
|
||||||
|
setDateFilterBy('');
|
||||||
|
setDateStart('');
|
||||||
|
setDateEnd('');
|
||||||
|
setSortBy('modified desc');
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.delete('status');
|
||||||
|
next.delete('priority');
|
||||||
|
next.delete('q');
|
||||||
|
next.delete('date_filter_by');
|
||||||
|
next.delete('date_start');
|
||||||
|
next.delete('date_end');
|
||||||
|
next.delete('sort_by');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = !!statusFilter || !!priorityFilter || !!searchQuery || !!(dateFilterBy && (dateStart || dateEnd));
|
||||||
|
|
||||||
|
const syncFiltersToUrl = useCallback(() => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (statusFilter) next.set('status', statusFilter);
|
||||||
|
else next.delete('status');
|
||||||
|
if (priorityFilter) next.set('priority', priorityFilter);
|
||||||
|
else next.delete('priority');
|
||||||
|
if (searchQuery) next.set('q', searchQuery);
|
||||||
|
else next.delete('q');
|
||||||
|
if (dateFilterBy) next.set('date_filter_by', dateFilterBy);
|
||||||
|
else next.delete('date_filter_by');
|
||||||
|
if (dateStart) next.set('date_start', dateStart);
|
||||||
|
else next.delete('date_start');
|
||||||
|
if (dateEnd) next.set('date_end', dateEnd);
|
||||||
|
else next.delete('date_end');
|
||||||
|
if (sortBy !== 'modified desc') next.set('sort_by', sortBy);
|
||||||
|
else next.delete('sort_by');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [statusFilter, priorityFilter, searchQuery, dateFilterBy, dateStart, dateEnd, sortBy, setSearchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) {
|
||||||
|
didInitUrlSync.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchParamsRef.current((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (statusFilter) next.set('status', statusFilter);
|
||||||
|
else next.delete('status');
|
||||||
|
if (priorityFilter) next.set('priority', priorityFilter);
|
||||||
|
else next.delete('priority');
|
||||||
|
if (dateFilterBy) next.set('date_filter_by', dateFilterBy);
|
||||||
|
else next.delete('date_filter_by');
|
||||||
|
if (dateStart) next.set('date_start', dateStart);
|
||||||
|
else next.delete('date_start');
|
||||||
|
if (dateEnd) next.set('date_end', dateEnd);
|
||||||
|
else next.delete('date_end');
|
||||||
|
if (sortBy !== 'modified desc') next.set('sort_by', sortBy);
|
||||||
|
else next.delete('sort_by');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [statusFilter, priorityFilter, dateFilterBy, dateStart, dateEnd, sortBy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) return;
|
||||||
|
if (skipInitialSearchUrlSync.current) {
|
||||||
|
skipInitialSearchUrlSync.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
searchDebounceRef.current = window.setTimeout(() => {
|
||||||
|
setSearchParamsRef.current((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (searchQuery) next.set('q', searchQuery);
|
||||||
|
else next.delete('q');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 450);
|
||||||
|
return () => {
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleEdit = (projectName: string) => navigate(`/projects/list/${encodeURIComponent(projectName)}?edit=1`);
|
||||||
|
const handleDuplicate = (projectName: string) => navigate(`/projects/list/new?duplicate=${encodeURIComponent(projectName)}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-4">
|
||||||
|
<button onClick={() => navigate('/projects')} className="text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400">{t('projects.moduleTitle')}</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{t('projects.projectsDoctype')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Page Header ──────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('projects.title')}</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{t('projects.listTotal')}
|
||||||
|
{totalCount} {totalCount !== 1 ? t('projects.listProjects') : t('projects.listProject')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||||||
|
• {selectedRows.size} {t('common.selected')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
<FaSync className="animate-spin h-3 w-3" />
|
||||||
|
{t('common.updating')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{/* ── Voice Command Assist ───────────────────────────────── */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowVoiceModal(true)}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all"
|
||||||
|
title="Bulk-update project status by voice"
|
||||||
|
>
|
||||||
|
<FaMicrophone />
|
||||||
|
<span className="font-medium">Voice Command Assist</span>
|
||||||
|
</button>
|
||||||
|
{/* ─────────────────────────────────────────────────────── */}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all disabled:opacity-50"
|
||||||
|
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport />
|
||||||
|
<span className="font-medium">{t('listPages.export')}</span>
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects/list/new')}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
<span className="font-medium">{t('projects.newProject')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Export Modal ─────────────────────────────────────────────── */}
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Project"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={projects.length}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageData={projects}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="projects"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Voice Status Modal ───────────────────────────────────────── */}
|
||||||
|
<VoiceStatusModal
|
||||||
|
isOpen={showVoiceModal}
|
||||||
|
onClose={() => setShowVoiceModal(false)}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
onUpdateSuccess={() => {
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
doctype="Project"
|
||||||
|
fieldname="status"
|
||||||
|
statusOptions={PROJECT_STATUS_OPTIONS}
|
||||||
|
widgetTitle="Voice Project Status Update"
|
||||||
|
showLanguageToggle={true}
|
||||||
|
noSelectionLabel="project"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Filter Panel ─────────────────────────────────────────────── */}
|
||||||
|
<div className="isolate bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3 rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsFilterExpanded(v => !v)} className="text-white hover:bg-white/20 p-2 rounded-lg transition-all">
|
||||||
|
{isFilterExpanded ? <FaChevronUp size={14} /> : <FaChevronDown size={14} />}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaFilter className="text-white" size={16} />
|
||||||
|
<span className="text-white font-semibold text-sm">{t('listPages.filters')}</span>
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||||
|
{[searchQuery, statusFilter, priorityFilter, dateFilterBy && dateStart].filter(Boolean).length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex-1 overflow-x-auto mx-2">
|
||||||
|
<div className="flex items-center gap-2 py-0.5">
|
||||||
|
{searchQuery && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Name:</span> {searchQuery}
|
||||||
|
<button onClick={() => setSearchQuery('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{statusFilter && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Status:</span> {statusFilter}
|
||||||
|
<button onClick={() => setStatusFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{priorityFilter && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Priority:</span> {priorityFilter}
|
||||||
|
<button onClick={() => setPriorityFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dateFilterBy && dateStart && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">{dateFilterBy === 'creation' ? 'Created' : 'Modified'}:</span> {dateStart}{dateEnd ? ` – ${dateEnd}` : ''}
|
||||||
|
<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={clearFilters} className="text-white/80 hover:text-white text-xs underline whitespace-nowrap">Clear all</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => refetch()} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all" title="Refresh">
|
||||||
|
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFilterExpanded && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Search</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||||
|
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && syncFiltersToUrl()} placeholder={t('projects.searchPlaceholder')}
|
||||||
|
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Completed">Completed</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Priority</label>
|
||||||
|
<select value={priorityFilter} onChange={e => setPriorityFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none">
|
||||||
|
<option value="">All Priority</option>
|
||||||
|
<option value="High">High</option>
|
||||||
|
<option value="Medium">Medium</option>
|
||||||
|
<option value="Low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Date Filter By</label>
|
||||||
|
<select value={dateFilterBy} onChange={e => setDateFilterBy(e.target.value as '' | 'creation' | 'modified')} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="creation">Created</option>
|
||||||
|
<option value="modified">Modified</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{dateFilterBy && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">From</label>
|
||||||
|
<input type="date" value={dateStart} onChange={e => setDateStart(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">To</label>
|
||||||
|
<input type="date" value={dateEnd} onChange={e => setDateEnd(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Sort By</label>
|
||||||
|
<select value={sortBy} onChange={e => setSortBy(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none">
|
||||||
|
<option value="modified desc">Modified (newest)</option>
|
||||||
|
<option value="creation desc">Created (newest)</option>
|
||||||
|
<option value="modified asc">Modified (oldest)</option>
|
||||||
|
<option value="creation asc">Created (oldest)</option>
|
||||||
|
<option value="name asc">Name A–Z</option>
|
||||||
|
<option value="name desc">Name Z–A</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Table ────────────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden relative">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('projects.noProjects')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 px-4 py-3 text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllOnPage}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
>
|
||||||
|
{allOnPageSelected
|
||||||
|
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||||||
|
: someOnPageSelected
|
||||||
|
? (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<FaSquare size={18} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-2 h-0.5 bg-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
{t('projects.projectName')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('commonFields.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('commonFields.priority')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('projects.customer')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('projects.expectedEnd')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('projects.progress')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('common.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{projects.map((project: Project) => (
|
||||||
|
<tr
|
||||||
|
key={project.name}
|
||||||
|
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(project.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}
|
||||||
|
onClick={() => navigate(`/projects/list/${project.name}`)}
|
||||||
|
>
|
||||||
|
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleRow(project.name)}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
aria-label={`Select ${project.name}`}
|
||||||
|
>
|
||||||
|
{selectedRows.has(project.name)
|
||||||
|
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-[15px] font-medium text-gray-900 dark:text-white hover:underline">
|
||||||
|
{project.project_name || project.name}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-gray-500 dark:text-gray-400">{project.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(project.status || '')}`}>
|
||||||
|
{project.status || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getPriorityStyle(project.priority || '')}`}>
|
||||||
|
{project.priority || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{project.customer || '-'}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{formatDate(project.expected_end_date || '')}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden max-w-[80px]">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full"
|
||||||
|
style={{ width: `${project.percent_complete ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">{project.percent_complete ?? 0}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/projects/list/${project.name}`)}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
|
||||||
|
title={t('common.view')}
|
||||||
|
aria-label={t('common.view')}
|
||||||
|
>
|
||||||
|
<FaEye />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(project.name)}
|
||||||
|
className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors"
|
||||||
|
title={t('common.edit', 'Edit')}
|
||||||
|
aria-label={t('common.edit', 'Edit')}
|
||||||
|
>
|
||||||
|
<FaEdit />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDuplicate(project.name)}
|
||||||
|
className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors"
|
||||||
|
title={t('common.duplicate', 'Duplicate')}
|
||||||
|
aria-label={t('common.duplicate', 'Duplicate')}
|
||||||
|
>
|
||||||
|
<FaCopy />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
|
<ListPagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectList;
|
||||||
495
pm_app/src/pages/ProjectModulePage.tsx
Normal file
495
pm_app/src/pages/ProjectModulePage.tsx
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
FaFolderOpen,
|
||||||
|
FaTasks,
|
||||||
|
FaClock,
|
||||||
|
FaSpinner,
|
||||||
|
FaPlus,
|
||||||
|
FaSync,
|
||||||
|
FaShoppingCart,
|
||||||
|
FaTruck,
|
||||||
|
FaFileInvoiceDollar,
|
||||||
|
FaMoneyBillWave,
|
||||||
|
FaClipboardList,
|
||||||
|
FaFileContract,
|
||||||
|
FaBoxOpen,
|
||||||
|
FaUsers,
|
||||||
|
FaUserFriends,
|
||||||
|
FaClone,
|
||||||
|
FaTags,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import projectService from '../services/projectService';
|
||||||
|
import salesOrderService from '../services/salesOrderService';
|
||||||
|
import deliveryNoteService from '../services/deliveryNoteService';
|
||||||
|
import salesInvoiceService from '../services/salesInvoiceService';
|
||||||
|
import { paymentEntryService } from '../services/paymentEntryService';
|
||||||
|
import materialRequestService from '../services/materialRequestService';
|
||||||
|
import purchaseOrderService from '../services/purchaseOrderService';
|
||||||
|
import purchaseReceiptService from '../services/purchaseReceiptService';
|
||||||
|
import masterService from '../services/masterService';
|
||||||
|
interface HubCounts {
|
||||||
|
projects: number | null;
|
||||||
|
tasks: number | null;
|
||||||
|
timesheets: number | null;
|
||||||
|
salesOrders: number | null;
|
||||||
|
deliveryNotes: number | null;
|
||||||
|
salesInvoices: number | null;
|
||||||
|
paymentEntries: number | null;
|
||||||
|
materialRequests: number | null;
|
||||||
|
purchaseOrders: number | null;
|
||||||
|
purchaseReceipts: number | null;
|
||||||
|
customers: number | null;
|
||||||
|
employees: number | null;
|
||||||
|
projectTemplates: number | null;
|
||||||
|
activityTypes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyCounts = (): HubCounts => ({
|
||||||
|
projects: null,
|
||||||
|
tasks: null,
|
||||||
|
timesheets: null,
|
||||||
|
salesOrders: null,
|
||||||
|
deliveryNotes: null,
|
||||||
|
salesInvoices: null,
|
||||||
|
paymentEntries: null,
|
||||||
|
materialRequests: null,
|
||||||
|
purchaseOrders: null,
|
||||||
|
purchaseReceipts: null,
|
||||||
|
customers: null,
|
||||||
|
employees: null,
|
||||||
|
projectTemplates: null,
|
||||||
|
activityTypes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Card: tinted panel, themed icon */
|
||||||
|
const ModuleCard: React.FC<{
|
||||||
|
label: string;
|
||||||
|
sub?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
iconClassName?: string;
|
||||||
|
cardClassName?: string;
|
||||||
|
count?: number | null;
|
||||||
|
loading?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
onNew?: () => void;
|
||||||
|
secondaryNew?: { onClick: () => void; title: string; icon: React.ReactNode };
|
||||||
|
}> = ({
|
||||||
|
label, sub, icon, count, loading, onClick, onNew,
|
||||||
|
iconClassName = 'bg-gradient-to-br from-blue-600 to-indigo-700',
|
||||||
|
cardClassName = '',
|
||||||
|
secondaryNew,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={`group rounded-lg border overflow-hidden hover:shadow-md transition-all cursor-pointer shadow-sm h-full min-w-0 flex flex-col ${cardClassName || 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-blue-300/80 dark:hover:border-blue-600'}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="p-2 flex-1 flex flex-col min-h-0">
|
||||||
|
<div className="flex items-start justify-between mb-1 gap-1">
|
||||||
|
<div className={`w-7 h-7 rounded-md ${iconClassName} flex items-center justify-center text-white shadow-sm shrink-0`}>
|
||||||
|
<span className="text-xs">{icon}</span>
|
||||||
|
</div>
|
||||||
|
{(onNew || secondaryNew) && (
|
||||||
|
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{onNew && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNew();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded-md bg-blue-600 text-white border border-blue-600 hover:bg-blue-700 shadow-sm"
|
||||||
|
title={`New ${label}`}
|
||||||
|
>
|
||||||
|
<FaPlus size={9} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{secondaryNew && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
secondaryNew.onClick();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded-md border border-violet-200 dark:border-violet-700 bg-violet-50 dark:bg-violet-900/40 text-violet-700 dark:text-violet-200 hover:bg-violet-100 dark:hover:bg-violet-900/60"
|
||||||
|
title={secondaryNew.title}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] flex items-center gap-0.5 font-bold">
|
||||||
|
<FaPlus size={7} />{secondaryNew.icon}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white text-xs leading-tight">{label}</p>
|
||||||
|
{sub && <p className="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 leading-snug line-clamp-2">{sub}</p>}
|
||||||
|
{count !== undefined && (
|
||||||
|
<p className="text-base font-bold text-gray-900 dark:text-white mt-auto pt-1 leading-none tabular-nums">
|
||||||
|
{loading && count === null ? <FaSpinner className="animate-spin text-base text-gray-400" /> : (count ?? 0)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Fills available width evenly — avoids empty space when few tiles sit in a half-width column */
|
||||||
|
const fluidTileGridClass =
|
||||||
|
'grid gap-1.5 w-full [grid-template-columns:repeat(auto-fit,minmax(6.75rem,1fr))]';
|
||||||
|
|
||||||
|
const SectionCard: React.FC<{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
headerClassName?: string;
|
||||||
|
bodyClassName?: string;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({
|
||||||
|
title, subtitle, icon, children,
|
||||||
|
headerClassName = 'bg-gradient-to-r from-slate-600 to-slate-800 dark:from-slate-700 dark:to-slate-900',
|
||||||
|
bodyClassName = '',
|
||||||
|
className = '',
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg overflow-hidden shadow-sm border border-gray-200/70 dark:border-gray-700/80 ring-1 ring-black/[0.03] dark:ring-white/[0.06] flex flex-col min-h-0 h-full ${className}`}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 text-white shadow-inner shrink-0 ${headerClassName}`}>
|
||||||
|
<span className="opacity-95 drop-shadow-sm shrink-0">{icon}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-xs font-semibold leading-tight tracking-tight">{title}</h2>
|
||||||
|
{subtitle && <p className="text-[9px] text-white/85 leading-snug mt-0.5 line-clamp-2">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`p-2 flex-1 min-h-0 ${bodyClassName}`}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ProjectModulePage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [counts, setCounts] = useState<HubCounts>(emptyCounts);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setCounts(emptyCounts());
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled([
|
||||||
|
projectService.getModuleCounts(),
|
||||||
|
salesOrderService.getSalesOrderCount([]),
|
||||||
|
deliveryNoteService.getDeliveryNoteCount([]),
|
||||||
|
salesInvoiceService.getSalesInvoiceCount({}),
|
||||||
|
paymentEntryService.getPaymentEntryCount([]),
|
||||||
|
materialRequestService.getMaterialRequestCount([]),
|
||||||
|
purchaseOrderService.getPurchaseOrderCount([]),
|
||||||
|
purchaseReceiptService.getPurchaseReceiptCount([]),
|
||||||
|
masterService.getCustomerCount({}),
|
||||||
|
masterService.getEmployeeCount({}),
|
||||||
|
projectService.getProjectTemplateCount({}),
|
||||||
|
projectService.getActivityTypeCount({}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const num = (i: number): number => {
|
||||||
|
const r = settled[i];
|
||||||
|
return r.status === 'fulfilled' ? (r.value as number) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const module =
|
||||||
|
settled[0].status === 'fulfilled'
|
||||||
|
? (settled[0].value as { projects: number; tasks: number; timesheets: number })
|
||||||
|
: { projects: 0, tasks: 0, timesheets: 0 };
|
||||||
|
|
||||||
|
setCounts({
|
||||||
|
projects: module.projects,
|
||||||
|
tasks: module.tasks,
|
||||||
|
timesheets: module.timesheets,
|
||||||
|
salesOrders: num(1),
|
||||||
|
deliveryNotes: num(2),
|
||||||
|
salesInvoices: num(3),
|
||||||
|
paymentEntries: num(4),
|
||||||
|
materialRequests: num(5),
|
||||||
|
purchaseOrders: num(6),
|
||||||
|
purchaseReceipts: num(7),
|
||||||
|
customers: num(8),
|
||||||
|
employees: num(9),
|
||||||
|
projectTemplates: num(10),
|
||||||
|
activityTypes: num(11),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const flowLoading = loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 min-h-screen bg-gray-50 dark:bg-gray-900 max-w-[1920px] mx-auto">
|
||||||
|
<div className="flex flex-col gap-2 mb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-md ring-2 ring-white/50 dark:ring-gray-700/50">
|
||||||
|
<FaFolderOpen className="text-white text-sm drop-shadow" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||||
|
{t('projects.moduleTitle', 'Project Management')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
||||||
|
{t('projects.hubSubtitle', 'Projects, tasks, timesheets, and linked other documents.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refresh}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-700 dark:text-gray-300 dark:hover:text-blue-400 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 shadow-sm self-start sm:self-center"
|
||||||
|
title={t('common.refresh', 'Refresh')}
|
||||||
|
>
|
||||||
|
<FaSync size={13} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary pills */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects/list')}
|
||||||
|
className="flex items-center gap-2 text-xs font-semibold px-3 py-1.5 rounded-full border border-sky-200 dark:border-sky-700 bg-gradient-to-r from-sky-100 to-blue-100 dark:from-sky-900/40 dark:to-blue-900/30 text-sky-950 dark:text-sky-100 shadow-sm hover:shadow transition-shadow"
|
||||||
|
>
|
||||||
|
<span className="text-blue-800 dark:text-sky-300 tabular-nums font-bold">
|
||||||
|
{loading && counts.projects === null ? '…' : (counts.projects ?? 0)}
|
||||||
|
</span>
|
||||||
|
Open projects
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects/tasks')}
|
||||||
|
className="flex items-center gap-2 text-xs font-semibold px-3 py-1.5 rounded-full border border-indigo-200 dark:border-indigo-700 bg-gradient-to-r from-indigo-50 to-violet-100 dark:from-indigo-900/35 dark:to-violet-900/25 text-gray-800 dark:text-indigo-100 shadow-sm hover:shadow transition-shadow"
|
||||||
|
>
|
||||||
|
<span className="text-indigo-700 dark:text-indigo-300 tabular-nums font-bold">
|
||||||
|
{loading && counts.tasks === null ? '…' : (counts.tasks ?? 0)}
|
||||||
|
</span>
|
||||||
|
Tasks
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects/timesheets')}
|
||||||
|
className="flex items-center gap-2 text-xs font-semibold px-3 py-1.5 rounded-full border border-emerald-200 dark:border-emerald-700 bg-gradient-to-r from-emerald-50 to-teal-100 dark:from-emerald-900/35 dark:to-teal-900/25 text-gray-800 dark:text-emerald-100 shadow-sm hover:shadow transition-shadow"
|
||||||
|
>
|
||||||
|
<span className="text-emerald-700 dark:text-emerald-300 tabular-nums font-bold">
|
||||||
|
{loading && counts.timesheets === null ? '…' : (counts.timesheets ?? 0)}
|
||||||
|
</span>
|
||||||
|
Timesheets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two columns on lg+: (Masters + Sales) | (Project & task + Buying) — halves screen, less scroll */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2 lg:gap-3 lg:items-stretch">
|
||||||
|
<div className="flex flex-col gap-2 min-w-0">
|
||||||
|
<SectionCard
|
||||||
|
title="Masters"
|
||||||
|
subtitle="Customers, employees, templates, and activity types."
|
||||||
|
icon={<FaUsers size={14} />}
|
||||||
|
headerClassName="bg-gradient-to-r from-purple-600 to-violet-700 dark:from-purple-700 dark:to-violet-800"
|
||||||
|
bodyClassName="bg-white dark:bg-gray-900/40"
|
||||||
|
>
|
||||||
|
<div className={fluidTileGridClass}>
|
||||||
|
<ModuleCard
|
||||||
|
label="Customers"
|
||||||
|
sub="Customer master"
|
||||||
|
icon={<FaUsers className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-violet-500 to-purple-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-violet-50/40 dark:from-gray-800 dark:to-violet-950/20 border-violet-200/50 dark:border-violet-800/50"
|
||||||
|
count={counts.customers}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => navigate('/customers')}
|
||||||
|
onNew={() => navigate('/customers/new')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Employees"
|
||||||
|
sub="Employee master"
|
||||||
|
icon={<FaUserFriends className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-fuchsia-500 to-pink-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-fuchsia-50/35 dark:from-gray-800 dark:to-fuchsia-950/15 border-fuchsia-200/50 dark:border-fuchsia-800/50"
|
||||||
|
count={counts.employees}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => navigate('/employees')}
|
||||||
|
onNew={() => navigate('/employees/new')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Project template"
|
||||||
|
sub="Reusable project templates"
|
||||||
|
icon={<FaClone className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-purple-600 to-indigo-800"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-purple-50/35 dark:from-gray-800 dark:to-purple-950/20 border-purple-200/50 dark:border-purple-800/50"
|
||||||
|
count={counts.projectTemplates}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => navigate('/projects/templates')}
|
||||||
|
onNew={() => navigate('/projects/templates/new')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Activity type"
|
||||||
|
sub="Billing categories"
|
||||||
|
icon={<FaTags className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-cyan-500 to-blue-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-cyan-50/30 dark:from-gray-800 dark:to-cyan-950/15 border-cyan-200/50 dark:border-cyan-800/50"
|
||||||
|
count={counts.activityTypes}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => navigate('/projects/activity-types')}
|
||||||
|
onNew={() => navigate('/projects/activity-types/new')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
title="Sales flow"
|
||||||
|
subtitle="Sales Order -> Delivery Note -> Sales Invoice -> Payment"
|
||||||
|
icon={<FaShoppingCart size={14} />}
|
||||||
|
headerClassName="bg-gradient-to-r from-green-600 to-emerald-700 dark:from-green-700 dark:to-emerald-800"
|
||||||
|
bodyClassName="bg-white dark:bg-gray-900/40"
|
||||||
|
>
|
||||||
|
<div className={fluidTileGridClass}>
|
||||||
|
<ModuleCard
|
||||||
|
label="Sales Order"
|
||||||
|
sub="Sales flow"
|
||||||
|
icon={<FaShoppingCart className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-emerald-600 to-teal-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-emerald-50/45 dark:from-gray-800 dark:to-emerald-950/20 border-emerald-200/55 dark:border-emerald-800/50"
|
||||||
|
count={counts.salesOrders}
|
||||||
|
loading={flowLoading}
|
||||||
|
onClick={() => navigate('/sales-orders')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Delivery Note"
|
||||||
|
sub="Sales flow"
|
||||||
|
icon={<FaTruck className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-cyan-600 to-blue-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-cyan-50/40 dark:from-gray-800 dark:to-cyan-950/15 border-cyan-200/55 dark:border-cyan-800/50"
|
||||||
|
count={counts.deliveryNotes}
|
||||||
|
loading={flowLoading}
|
||||||
|
onClick={() => navigate('/delivery-notes')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Sales Invoice"
|
||||||
|
sub="Sales flow"
|
||||||
|
icon={<FaFileInvoiceDollar className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-blue-600 to-indigo-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-blue-50/40 dark:from-gray-800 dark:to-blue-950/20 border-blue-200/55 dark:border-blue-800/50"
|
||||||
|
count={counts.salesInvoices}
|
||||||
|
loading={flowLoading}
|
||||||
|
onClick={() => navigate('/invoices')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Payment Entry"
|
||||||
|
sub="Sales flow"
|
||||||
|
icon={<FaMoneyBillWave className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-violet-600 to-purple-800"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-violet-50/40 dark:from-gray-800 dark:to-violet-950/20 border-violet-200/55 dark:border-violet-800/50"
|
||||||
|
count={counts.paymentEntries}
|
||||||
|
loading={flowLoading}
|
||||||
|
onClick={() => navigate('/payment-entries')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 min-w-0">
|
||||||
|
<SectionCard
|
||||||
|
title="Project & task management"
|
||||||
|
subtitle="Core project monitoring and planning."
|
||||||
|
icon={<FaFolderOpen size={14} />}
|
||||||
|
headerClassName="bg-gradient-to-r from-blue-600 to-indigo-700 dark:from-blue-700 dark:to-indigo-800"
|
||||||
|
bodyClassName="bg-white dark:bg-gray-900/40"
|
||||||
|
>
|
||||||
|
<div className={fluidTileGridClass}>
|
||||||
|
<ModuleCard
|
||||||
|
label="Projects"
|
||||||
|
sub="Open projects"
|
||||||
|
icon={<FaFolderOpen className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-sky-500 to-blue-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-sky-50/50 dark:from-gray-800 dark:to-sky-950/20 border-sky-200/60 dark:border-sky-800/50"
|
||||||
|
count={counts.projects}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => navigate('/projects/list')}
|
||||||
|
onNew={() => navigate('/projects/list/new')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Tasks"
|
||||||
|
sub="All tasks"
|
||||||
|
icon={<FaTasks className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-indigo-500 to-violet-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-indigo-50/40 dark:from-gray-800 dark:to-indigo-950/20 border-indigo-200/50 dark:border-indigo-800/50"
|
||||||
|
count={counts.tasks}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => navigate('/projects/tasks')}
|
||||||
|
onNew={() => navigate('/projects/tasks/new')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Timesheets"
|
||||||
|
sub="Time logs"
|
||||||
|
icon={<FaClock className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-emerald-500 to-teal-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-emerald-50/40 dark:from-gray-800 dark:to-emerald-950/20 border-emerald-200/50 dark:border-emerald-800/50"
|
||||||
|
count={counts.timesheets}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => navigate('/projects/timesheets')}
|
||||||
|
onNew={() => navigate('/projects/timesheets/new')}
|
||||||
|
secondaryNew={{
|
||||||
|
title: 'New Sales Order',
|
||||||
|
icon: <FaShoppingCart size={10} />,
|
||||||
|
onClick: () => navigate('/sales-orders/new'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
title="Buying / material flow"
|
||||||
|
subtitle="Material Request -> Purchase Order -> Purchase Receipt"
|
||||||
|
icon={<FaBoxOpen size={14} />}
|
||||||
|
headerClassName="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-600 dark:from-amber-600 dark:via-orange-600 dark:to-rose-700"
|
||||||
|
bodyClassName="bg-white dark:bg-gray-900/40"
|
||||||
|
>
|
||||||
|
<div className={fluidTileGridClass}>
|
||||||
|
<ModuleCard
|
||||||
|
label="Material Request"
|
||||||
|
sub="Buying flow"
|
||||||
|
icon={<FaClipboardList className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-amber-500 to-orange-600"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-amber-50/45 dark:from-gray-800 dark:to-amber-950/20 border-amber-200/55 dark:border-amber-800/50"
|
||||||
|
count={counts.materialRequests}
|
||||||
|
loading={flowLoading}
|
||||||
|
onClick={() => navigate('/material-requests')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Purchase Order"
|
||||||
|
sub="Buying flow"
|
||||||
|
icon={<FaFileContract className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-orange-600 to-red-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-orange-50/40 dark:from-gray-800 dark:to-orange-950/20 border-orange-200/55 dark:border-orange-800/50"
|
||||||
|
count={counts.purchaseOrders}
|
||||||
|
loading={flowLoading}
|
||||||
|
onClick={() => navigate('/purchase-orders')}
|
||||||
|
/>
|
||||||
|
<ModuleCard
|
||||||
|
label="Purchase Receipt"
|
||||||
|
sub="Buying flow"
|
||||||
|
icon={<FaBoxOpen className="text-white" />}
|
||||||
|
iconClassName="bg-gradient-to-br from-rose-600 to-pink-700"
|
||||||
|
cardClassName="bg-gradient-to-b from-white to-rose-50/40 dark:from-gray-800 dark:to-rose-950/20 border-rose-200/55 dark:border-rose-800/50"
|
||||||
|
count={counts.purchaseReceipts}
|
||||||
|
loading={flowLoading}
|
||||||
|
onClick={() => navigate('/purchase-receipts')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectModulePage;
|
||||||
2665
pm_app/src/pages/ProjectReportsDashboard.tsx
Normal file
2665
pm_app/src/pages/ProjectReportsDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
302
pm_app/src/pages/ProjectTemplateDetail.tsx
Normal file
302
pm_app/src/pages/ProjectTemplateDetail.tsx
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||||
|
FaCheckCircle, FaTimesCircle, FaSpinner, FaInfoCircle,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import { useProjectTemplateDetails, useProjectMutations } from '../hooks/useProject';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
import type { ProjectTemplate, ProjectTemplateTask } from '../services/projectService';
|
||||||
|
|
||||||
|
// Confirmed from Frappe desk: Project Template Task uses `task` (Link to Task, is_template=1)
|
||||||
|
// and `subject` is auto-fetched from the linked task. `duration` is in days.
|
||||||
|
|
||||||
|
const emptyTask = (): ProjectTemplateTask => ({
|
||||||
|
task: '',
|
||||||
|
subject: '',
|
||||||
|
duration: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Only keep rows that have a task selected; strip empty/undefined fields */
|
||||||
|
const sanitizeTemplateTasks = (tasks: ProjectTemplateTask[]): ProjectTemplateTask[] =>
|
||||||
|
tasks
|
||||||
|
.filter(t => t.task?.trim())
|
||||||
|
.map(({ task, duration }) => ({
|
||||||
|
task: task!.trim(),
|
||||||
|
...(duration !== undefined ? { duration } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ProjectTemplateDetail: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { templateName } = useParams<{ templateName: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = templateName === 'new';
|
||||||
|
|
||||||
|
const { template, loading, error, refetch } = useProjectTemplateDetails(isNew ? null : (templateName || null));
|
||||||
|
const { createProjectTemplate, updateProjectTemplate, loading: saving } = useProjectMutations();
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Partial<ProjectTemplate>>({
|
||||||
|
name: '',
|
||||||
|
project_type: '',
|
||||||
|
tasks: [emptyTask()],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (template && !isNew) {
|
||||||
|
setForm({
|
||||||
|
name: template.name || '',
|
||||||
|
project_type: template.project_type || '',
|
||||||
|
tasks: template.tasks?.length ? template.tasks : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [template, isNew]);
|
||||||
|
|
||||||
|
const setField = (k: keyof ProjectTemplate, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const addTask = () => setForm(f => ({
|
||||||
|
...f,
|
||||||
|
tasks: [...(f.tasks || []), emptyTask()],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateTask = (idx: number, k: keyof ProjectTemplateTask, v: any) => setForm(f => {
|
||||||
|
const tasks = [...(f.tasks || [])];
|
||||||
|
tasks[idx] = { ...tasks[idx], [k]: v };
|
||||||
|
return { ...f, tasks };
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeTask = (idx: number) => setForm(f => {
|
||||||
|
const tasks = [...(f.tasks || [])];
|
||||||
|
tasks.splice(idx, 1);
|
||||||
|
return { ...f, tasks };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name?.trim()) { toast.error('Template name is required'); return; }
|
||||||
|
|
||||||
|
const cleanTasks = sanitizeTemplateTasks(form.tasks || []);
|
||||||
|
const payload: Partial<ProjectTemplate> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
// Only send project_type if non-empty — empty string causes Link validation errors
|
||||||
|
...(form.project_type?.trim() ? { project_type: form.project_type.trim() } : {}),
|
||||||
|
tasks: cleanTasks,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
const created = await createProjectTemplate(payload);
|
||||||
|
toast.success(t('projects.templateCreated'), { icon: <FaCheckCircle /> });
|
||||||
|
navigate(`/projects/templates/${encodeURIComponent(created.name)}`);
|
||||||
|
} else {
|
||||||
|
await updateProjectTemplate(templateName!, payload);
|
||||||
|
toast.success(t('projects.templateUpdated'), { icon: <FaCheckCircle /> });
|
||||||
|
setIsEditing(false);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('common.error'), { icon: <FaTimesCircle /> });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls = (ed: boolean) =>
|
||||||
|
`w-full px-3 py-2 text-sm border rounded-lg ${ed
|
||||||
|
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-400'
|
||||||
|
: 'border-transparent bg-gray-50 dark:bg-gray-800 text-gray-800 dark:text-gray-200 cursor-default'}`;
|
||||||
|
|
||||||
|
const editable = isNew || isEditing;
|
||||||
|
|
||||||
|
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-purple-500 text-3xl" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3500} />
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-6">
|
||||||
|
<button onClick={() => navigate('/projects')} className="text-gray-500 hover:text-indigo-600 dark:text-gray-400">{t('projects.moduleTitle')}</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<button onClick={() => navigate('/projects/templates')} className="text-gray-500 hover:text-purple-600 dark:text-gray-400">{t('projects.projectTemplateDoctype')}</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{isNew ? t('projects.newProjectTemplate') : templateName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/projects/templates')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"><FaArrowLeft /></button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{isNew ? t('projects.newProjectTemplate') : (form.name || templateName)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isNew && !isEditing && (
|
||||||
|
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-purple-500 text-purple-600 dark:text-purple-400 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 text-sm">
|
||||||
|
<FaEdit /> {t('common.edit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 text-sm">
|
||||||
|
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}
|
||||||
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
{!isNew && (
|
||||||
|
<button onClick={() => setIsEditing(false)} className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"><FaTimes /></button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !isNew && (
|
||||||
|
<div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded text-red-700 dark:text-red-300 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header fields */}
|
||||||
|
<div className="p-6 grid grid-cols-1 sm:grid-cols-2 gap-4 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name || ''}
|
||||||
|
onChange={e => setField('name', e.target.value)}
|
||||||
|
disabled={!isNew}
|
||||||
|
className={inputCls(isNew)}
|
||||||
|
placeholder="Template name..."
|
||||||
|
/>
|
||||||
|
{!isNew && <p className="text-xs text-gray-400 mt-1">Name cannot be changed after creation</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Project Type</label>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Project Type" hideLabel value={form.project_type || ''} onChange={v => setField('project_type', v)} doctype="Project Type" placeholder="Select project type..." />
|
||||||
|
: <div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200">{form.project_type || '-'}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks child table */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Tasks</h3>
|
||||||
|
{editable && (
|
||||||
|
<button onClick={addTask} className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white text-xs rounded-lg hover:bg-purple-700">
|
||||||
|
<FaPlus /> Add Row
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info banner */}
|
||||||
|
<div className="flex items-start gap-2 mb-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
<FaInfoCircle className="mt-0.5 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
Each row links to a <strong>Task</strong> that has <strong>Is Template = Yes</strong>.
|
||||||
|
To create a new template task, first go to{' '}
|
||||||
|
<button onClick={() => navigate('/projects/tasks/new')} className="underline hover:text-blue-900 dark:hover:text-blue-100">Tasks → New Task</button>
|
||||||
|
{' '}and check "Is Template", then come back and select it here.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(form.tasks || []).length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-gray-400 dark:text-gray-500 text-sm bg-gray-50 dark:bg-gray-900/30 rounded-lg border border-dashed border-gray-300 dark:border-gray-600">
|
||||||
|
{editable ? 'Click "Add Row" to link template tasks' : 'No tasks defined'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-10">No.</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase min-w-[280px]">Task * <span className="font-normal normal-case text-gray-400">(is_template=Yes)</span></th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Subject</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-28">Duration (days)</th>
|
||||||
|
{editable && <th className="px-3 py-2 w-10" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{(form.tasks || []).map((task, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td className="px-3 py-2 text-gray-500 text-xs font-medium">{idx + 1}</td>
|
||||||
|
|
||||||
|
{/* task — Link to Task (is_template=1) */}
|
||||||
|
<td className="px-2 py-1.5 min-w-[280px]">
|
||||||
|
{editable
|
||||||
|
? <LinkField
|
||||||
|
label="Task"
|
||||||
|
hideLabel
|
||||||
|
value={task.task || ''}
|
||||||
|
onChange={v => updateTask(idx, 'task', v)}
|
||||||
|
doctype="Task"
|
||||||
|
placeholder="Search template task..."
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
: <span className="text-gray-800 dark:text-gray-200 font-medium">{task.task || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* subject — auto-fetched from linked task, read-only */}
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 text-xs italic">
|
||||||
|
{task.subject || '(from task)'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* duration in days */}
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
{editable
|
||||||
|
? <input
|
||||||
|
type="number" min={0}
|
||||||
|
value={task.duration ?? ''}
|
||||||
|
onChange={e => updateTask(idx, 'duration', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="0"
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-purple-400"
|
||||||
|
/>
|
||||||
|
: <span className="text-gray-700 dark:text-gray-300 text-sm">{task.duration ?? '-'}</span>}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<button onClick={() => removeTask(idx)} className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||||
|
<FaTrash size={12} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
{!isNew && template && (
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-100 dark:border-gray-700 grid grid-cols-2 gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div><span className="font-medium block">Created</span>{template.creation ? new Date(template.creation).toLocaleString() : '-'}</div>
|
||||||
|
<div><span className="font-medium block">Modified</span>{template.modified ? new Date(template.modified).toLocaleString() : '-'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Project Template"
|
||||||
|
docname={template?.name || templateName || ''}
|
||||||
|
creationDate={template?.creation}
|
||||||
|
createdBy={template?.owner}
|
||||||
|
compact={false}
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectTemplateDetail;
|
||||||
224
pm_app/src/pages/ProjectTemplateList.tsx
Normal file
224
pm_app/src/pages/ProjectTemplateList.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaPlus, FaSearch, FaSync, FaClone, FaEye, FaFileExport } from 'react-icons/fa';
|
||||||
|
import { useProjectTemplates } from '../hooks/useProject';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const ProjectTemplateList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const f: Record<string, unknown> = {};
|
||||||
|
if (search.trim()) f.name = ['like', `%${search.trim()}%`];
|
||||||
|
return f;
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const { templates, loading, totalCount, refetch } = useProjectTemplates({
|
||||||
|
filters: apiFilters,
|
||||||
|
limit_start: page * PAGE_SIZE,
|
||||||
|
limit_page_length: PAGE_SIZE,
|
||||||
|
order_by: 'name asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure pagination always triggers data reload (some environments cache identical queries).
|
||||||
|
useEffect(() => { refetch(); }, [page, apiFilters, refetch]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(templates, selectionResetKey);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() => fetchAllRowsForExport({ doctype: 'Project Template', filters: apiFilters, orderBy: 'name asc' }),
|
||||||
|
[apiFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects')}
|
||||||
|
className="text-sm text-gray-500 hover:text-violet-600 dark:text-gray-400 dark:hover:text-violet-400"
|
||||||
|
>
|
||||||
|
{t('projects.moduleTitle')}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<FaClone className="text-violet-500" /> {t('projects.projectTemplateDoctype')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="p-2 text-gray-500 border border-gray-200 dark:border-gray-600 rounded-lg hover:text-violet-600"
|
||||||
|
aria-label="Refresh"
|
||||||
|
>
|
||||||
|
<FaSync size={14} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects/templates/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<FaPlus size={12} /> New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap gap-3 items-center">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||||
|
placeholder="Search template…"
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">{totalCount} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Project Template"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={templates.length}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageData={templates}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="project_templates"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="w-10 px-2 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
ref={el => {
|
||||||
|
if (el) el.indeterminate = someOnPageSelected;
|
||||||
|
}}
|
||||||
|
onChange={toggleAllOnPage}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Name</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Project type</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Modified</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-24"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-10 text-gray-400">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-10 text-gray-400">No templates found</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
templates.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.name}
|
||||||
|
className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${selectedRows.has(row.name) ? 'bg-violet-50/80 dark:bg-violet-900/20' : ''}`}
|
||||||
|
onClick={() => navigate(`/projects/templates/${encodeURIComponent(row.name)}`)}
|
||||||
|
>
|
||||||
|
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
|
||||||
|
checked={selectedRows.has(row.name)}
|
||||||
|
onChange={() => toggleRow(row.name)}
|
||||||
|
aria-label={`Select ${row.name}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-medium text-violet-600">{row.name}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-600 dark:text-gray-300">{row.project_type || '—'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500 text-xs">{row.modified ? new Date(row.modified).toLocaleDateString() : '—'}</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/projects/templates/${encodeURIComponent(row.name)}`);
|
||||||
|
}}
|
||||||
|
className="text-violet-600 hover:text-violet-800 p-1"
|
||||||
|
aria-label="View"
|
||||||
|
>
|
||||||
|
<FaEye />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{totalCount > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={(page + 1) * PAGE_SIZE >= totalCount}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectTemplateList;
|
||||||
900
pm_app/src/pages/PurchaseOrderDetail.tsx
Normal file
900
pm_app/src/pages/PurchaseOrderDetail.tsx
Normal file
@ -0,0 +1,900 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||||
|
FaSpinner, FaShoppingBag, FaPaperPlane,
|
||||||
|
FaChevronDown, FaChevronRight, FaPencilAlt,
|
||||||
|
FaFileInvoice,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import purchaseOrderService, { PurchaseOrder, PurchaseOrderItem, PurchaseTaxCharge } from '../services/purchaseOrderService';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
import { DEFAULT_COMPANY, DEFAULT_CURRENCY, displayTxnCurrency } from '../constants/orgDefaults';
|
||||||
|
|
||||||
|
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded min-h-[34px] flex items-center">
|
||||||
|
{children || <span className="text-gray-400">-</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-amber-400';
|
||||||
|
const numCls = inputCls + ' text-right';
|
||||||
|
const inlineNum = 'w-full px-2 py-1 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-amber-400';
|
||||||
|
const inlineTxt = 'w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-amber-400';
|
||||||
|
|
||||||
|
// ── Create Dropdown ───────────────────────────────────────────────────────────
|
||||||
|
const CreateDropdown: React.FC<{
|
||||||
|
items: { label: string; icon: React.ReactNode; onClick: () => void }[];
|
||||||
|
}> = ({ items }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 text-sm font-medium shadow-sm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
<FaChevronDown size={10} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-1 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 py-1.5 overflow-hidden">
|
||||||
|
<div className="px-3 py-1.5 text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider border-b border-gray-100 dark:border-gray-700 mb-1">
|
||||||
|
Create from this order
|
||||||
|
</div>
|
||||||
|
{items.map(({ label, icon, onClick }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
onClick={() => { onClick(); setOpen(false); }}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 hover:text-amber-700 dark:hover:text-amber-300 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className="text-gray-400">{icon}</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Collapsible group inside row editor ───────────────────────────────────────
|
||||||
|
const RGroup: React.FC<{ title: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ title, children, defaultOpen = false }) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-600 mt-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(o => !o)}
|
||||||
|
className="flex items-center gap-2 py-1 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline">
|
||||||
|
{open ? <FaChevronDown size={9} /> : <FaChevronRight size={9} />}{title}
|
||||||
|
</button>
|
||||||
|
{open && <div className="mt-2">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Item Row Editor ───────────────────────────────────────────────────────────
|
||||||
|
const POItemRowEditor: React.FC<{
|
||||||
|
item: Partial<PurchaseOrderItem>; rowNo: number;
|
||||||
|
onChange: (k: keyof PurchaseOrderItem, v: any) => void;
|
||||||
|
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
||||||
|
}> = ({ item, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="p-0">
|
||||||
|
<div className="border border-amber-300 dark:border-amber-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
||||||
|
{/* Editor header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-amber-50 dark:bg-amber-900/30 rounded-t-lg border-b border-amber-200 dark:border-amber-700">
|
||||||
|
<span className="text-sm font-semibold text-amber-700 dark:text-amber-300">Editing Row #{rowNo}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600">Insert Below</button>
|
||||||
|
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Row 1: Item Code + Schedule Date */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><FL required>Item Code</FL>
|
||||||
|
<LinkField label="Item" hideLabel doctype="Item" value={item.item_code || ''} onChange={v => onChange('item_code', v)} placeholder="Select item…" />
|
||||||
|
</div>
|
||||||
|
<div><FL>Schedule Date</FL>
|
||||||
|
<input type="date" value={item.schedule_date || ''} onChange={e => onChange('schedule_date', e.target.value)} className={inputCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item Name */}
|
||||||
|
<div><FL required>Item Name</FL>
|
||||||
|
<input value={item.item_name || ''} onChange={e => onChange('item_name', e.target.value)} className={inputCls} placeholder="Item name…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<RGroup title="Description" defaultOpen={!!(item.description)}>
|
||||||
|
<textarea rows={2} value={item.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Quantity and Rate */}
|
||||||
|
<RGroup title="Quantity and Rate" defaultOpen>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div><FL required>Quantity</FL>
|
||||||
|
<input type="number" min={0} step="1" value={item.qty ?? 0} onChange={e => onChange('qty', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL required>UOM</FL>
|
||||||
|
<LinkField label="UOM" hideLabel doctype="UOM" value={item.uom || ''} onChange={v => onChange('uom', v)} placeholder="UOM…" />
|
||||||
|
</div>
|
||||||
|
<div><FL>Stock UOM</FL>
|
||||||
|
<LinkField label="Stock UOM" hideLabel doctype="UOM" value={item.stock_uom || ''} onChange={v => onChange('stock_uom', v)} placeholder="Stock UOM…" />
|
||||||
|
</div>
|
||||||
|
<div><FL>UOM Conversion Factor</FL>
|
||||||
|
<input type="number" min={0} step="0.001" value={item.conversion_factor ?? 1} onChange={e => onChange('conversion_factor', parseFloat(e.target.value) || 1)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL>Stock Qty (auto)</FL>
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-600 bg-gray-50 dark:bg-gray-700 rounded text-right">
|
||||||
|
{((item.qty || 0) * (item.conversion_factor || 1)).toFixed(3)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><FL>Price List Rate</FL>
|
||||||
|
<input type="number" min={0} step="0.01" value={item.price_list_rate ?? 0} onChange={e => onChange('price_list_rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL>Last Purchase Rate</FL>
|
||||||
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{(item.last_purchase_rate ?? 0).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Discount and Margin */}
|
||||||
|
<RGroup title="Discount and Margin">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div><FL required>Rate</FL>
|
||||||
|
<input type="number" min={0} step="0.01" value={item.rate ?? 0} onChange={e => onChange('rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL>Amount (auto)</FL>
|
||||||
|
<div className="px-3 py-2 text-sm font-semibold text-right bg-gray-50 dark:bg-gray-700 rounded">
|
||||||
|
{((item.qty || 0) * (item.rate || 0)).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-end gap-2 pb-1">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!!(item.is_free_item)} onChange={e => onChange('is_free_item', e.target.checked ? 1 : 0)} className="rounded" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">Is Free Item</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Warehouse and Reference */}
|
||||||
|
<RGroup title="Warehouse and Reference">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><FL>Warehouse</FL>
|
||||||
|
<LinkField label="Warehouse" hideLabel doctype="Warehouse" value={item.warehouse || ''} onChange={v => onChange('warehouse', v)} placeholder="Warehouse…" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!!(item.against_blanket_order)} onChange={e => onChange('against_blanket_order', e.target.checked ? 1 : 0)} className="rounded" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">Against Blanket Order</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{item.material_request && (
|
||||||
|
<div><FL>Material Request</FL>
|
||||||
|
<RV>{item.material_request}</RV>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Available Quantity */}
|
||||||
|
<RGroup title="Available Quantity">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><FL>Actual Qty (Warehouse)</FL>
|
||||||
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{item.actual_qty ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div><FL>Company Total Stock</FL>
|
||||||
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">{item.company_total_stock ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Accounting Details */}
|
||||||
|
<RGroup title="Accounting Details">
|
||||||
|
<div><FL>Expense Account</FL>
|
||||||
|
<LinkField label="Account" hideLabel doctype="Account" value={item.expense_account || ''} onChange={v => onChange('expense_account', v)} placeholder="Expense account…" />
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Accounting Dimensions */}
|
||||||
|
<RGroup title="Accounting Dimensions">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><FL>Cost Center</FL>
|
||||||
|
<LinkField label="Cost Center" hideLabel doctype="Cost Center" value={item.cost_center || ''} onChange={v => onChange('cost_center', v)} placeholder="Cost center…" />
|
||||||
|
</div>
|
||||||
|
<div><FL>Project</FL>
|
||||||
|
<LinkField label="Project" hideLabel doctype="Project" value={item.project || ''} onChange={v => onChange('project', v)} placeholder="Project…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Item Weight Details */}
|
||||||
|
<RGroup title="Item Weight Details">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><FL>Weight Per Unit</FL>
|
||||||
|
<input type="number" min={0} step="0.001" value={item.weight_per_unit ?? 0} onChange={e => onChange('weight_per_unit', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL>Total Weight (auto)</FL>
|
||||||
|
<div className="px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700 rounded">
|
||||||
|
{((item.qty || 0) * (item.weight_per_unit || 0)).toFixed(3)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tax Row Editor ────────────────────────────────────────────────────────────
|
||||||
|
const POTaxRowEditor: React.FC<{
|
||||||
|
tax: Partial<PurchaseTaxCharge>; rowNo: number;
|
||||||
|
onChange: (k: keyof PurchaseTaxCharge, v: any) => void;
|
||||||
|
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
||||||
|
}> = ({ tax, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="p-0">
|
||||||
|
<div className="border border-amber-300 dark:border-amber-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-amber-50 dark:bg-amber-900/30 rounded-t-lg border-b border-amber-200">
|
||||||
|
<span className="text-sm font-semibold text-amber-700 dark:text-amber-300">Editing Row #{rowNo}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-600">Insert Below</button>
|
||||||
|
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><FL required>Type</FL>
|
||||||
|
<select value={tax.charge_type || ''} onChange={e => onChange('charge_type', e.target.value)} className={inputCls}>
|
||||||
|
<option value="">Select type…</option>
|
||||||
|
<option value="Actual">Actual</option>
|
||||||
|
<option value="On Net Total">On Net Total</option>
|
||||||
|
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><FL>Description</FL>
|
||||||
|
<textarea rows={3} value={tax.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><FL required>Account Head</FL>
|
||||||
|
<LinkField label="Account Head" hideLabel doctype="Account" value={tax.account_head || ''} onChange={v => onChange('account_head', v)} placeholder="Account…" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={!!(tax.included_in_print_rate)} onChange={e => onChange('included_in_print_rate', e.target.checked ? 1 : 0)} className="rounded" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">Is this Tax included in Basic Rate?</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-amber-500 dark:text-amber-400">If checked, the tax amount will be considered as already included in the Print Rate / Print Amount</p>
|
||||||
|
|
||||||
|
<RGroup title="Accounting Dimensions" defaultOpen>
|
||||||
|
<div><FL>Cost Center</FL>
|
||||||
|
<LinkField label="Cost Center" hideLabel doctype="Cost Center" value={tax.cost_center || ''} onChange={v => onChange('cost_center', v)} placeholder="Cost center…" />
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><FL required>Tax Rate</FL>
|
||||||
|
<input type="number" min={0} step="0.01" value={tax.rate ?? 0} onChange={e => onChange('rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL>Account Currency</FL>
|
||||||
|
<input value={tax.account_currency || DEFAULT_CURRENCY} onChange={e => onChange('account_currency', e.target.value)} className={inputCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
const emptyItem = (): Partial<PurchaseOrderItem> => ({ item_code: '', item_name: '', qty: 1, rate: 0, amount: 0, uom: '', conversion_factor: 1, is_free_item: 0 });
|
||||||
|
const emptyTax = (): Partial<PurchaseTaxCharge> => ({ charge_type: '', account_head: '', rate: 0, account_currency: DEFAULT_CURRENCY, included_in_print_rate: 0 });
|
||||||
|
|
||||||
|
const PurchaseOrderDetail: React.FC = () => {
|
||||||
|
const { poName } = useParams<{ poName: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = poName === 'new';
|
||||||
|
|
||||||
|
const contextProject = searchParams.get('project') || '';
|
||||||
|
const contextSupplier = searchParams.get('supplier') || '';
|
||||||
|
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||||
|
const contextMR = searchParams.get('mr') || '';
|
||||||
|
|
||||||
|
const [doc, setDoc] = useState<PurchaseOrder | null>(null);
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||||
|
const [expandedTax, setExpandedTax] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Partial<PurchaseOrder>>({
|
||||||
|
supplier: contextSupplier, supplier_name: contextSupplier,
|
||||||
|
company: contextCompany, project: contextProject,
|
||||||
|
transaction_date: today, schedule_date: today, currency: DEFAULT_CURRENCY,
|
||||||
|
items: [], taxes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncForm = useCallback((d: PurchaseOrder) => {
|
||||||
|
setForm({
|
||||||
|
supplier: d.supplier || '', supplier_name: d.supplier_name || d.supplier || '',
|
||||||
|
company: d.company || DEFAULT_COMPANY, project: d.project || '',
|
||||||
|
transaction_date: d.transaction_date || today,
|
||||||
|
schedule_date: d.schedule_date || '',
|
||||||
|
currency: d.currency || DEFAULT_CURRENCY,
|
||||||
|
cost_center: d.cost_center || '',
|
||||||
|
set_warehouse: d.set_warehouse || '',
|
||||||
|
tax_category: (d as any).tax_category || '',
|
||||||
|
taxes_and_charges: (d as any).taxes_and_charges || '',
|
||||||
|
items: d.items || [], taxes: d.taxes || [],
|
||||||
|
});
|
||||||
|
setExpandedItem(null); setExpandedTax(null);
|
||||||
|
}, [today]);
|
||||||
|
|
||||||
|
// Pre-fill from MR when creating a new PO from Material Request context
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew || !contextMR) return;
|
||||||
|
fetch(`/api/resource/Material Request/${encodeURIComponent(contextMR)}`, { credentials: 'include' })
|
||||||
|
.then(r => r.json()).then(body => {
|
||||||
|
const mr = body.data;
|
||||||
|
if (!mr) return;
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
company: mr.company || f.company,
|
||||||
|
project: mr.project || f.project,
|
||||||
|
schedule_date: mr.transaction_date || f.schedule_date,
|
||||||
|
items: (mr.items || []).map((it: any) => ({
|
||||||
|
item_code: it.item_code, item_name: it.item_name,
|
||||||
|
description: it.description,
|
||||||
|
qty: it.qty, uom: it.uom, stock_uom: it.stock_uom,
|
||||||
|
rate: 0, amount: 0,
|
||||||
|
schedule_date: it.required_by || f.schedule_date,
|
||||||
|
material_request: contextMR,
|
||||||
|
material_request_item: it.name,
|
||||||
|
warehouse: it.warehouse,
|
||||||
|
project: it.project || mr.project,
|
||||||
|
cost_center: it.cost_center,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [isNew, contextMR]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return;
|
||||||
|
setLoading(true);
|
||||||
|
purchaseOrderService.getPurchaseOrder(poName!)
|
||||||
|
.then(d => { setDoc(d); syncForm(d); })
|
||||||
|
.catch(e => toast.error(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [poName, isNew, syncForm]);
|
||||||
|
|
||||||
|
// Auto-fetch company default currency for new documents
|
||||||
|
useEffect(() => {
|
||||||
|
const company = form.company;
|
||||||
|
if (!isNew || !company) return;
|
||||||
|
fetch(`/api/resource/Company/${encodeURIComponent(company)}`, { credentials: 'include' })
|
||||||
|
.then(r => r.json()).then(b => {
|
||||||
|
if (b.data?.default_currency) {
|
||||||
|
const cur = displayTxnCurrency(b.data.default_currency);
|
||||||
|
setForm(f => ({ ...f, currency: cur,
|
||||||
|
buying_price_list: (f as any).buying_price_list || 'Standard Buying',
|
||||||
|
price_list_currency: (f as any).price_list_currency || cur,
|
||||||
|
} as any));
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [form.company, isNew]);
|
||||||
|
|
||||||
|
const set = (k: keyof PurchaseOrder, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
// ── Item helpers ──────────────────────────────────────────────────────────
|
||||||
|
const updateItem = (idx: number, k: keyof PurchaseOrderItem, v: any) =>
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
const updated = { ...items[idx], [k]: v };
|
||||||
|
if (k === 'qty' || k === 'rate') {
|
||||||
|
const qty = parseFloat(String(k === 'qty' ? v : updated.qty)) || 0;
|
||||||
|
const rate = parseFloat(String(k === 'rate' ? v : updated.rate)) || 0;
|
||||||
|
updated.amount = parseFloat((qty * rate).toFixed(4));
|
||||||
|
}
|
||||||
|
if (k === 'qty' || k === 'conversion_factor') {
|
||||||
|
const qty = parseFloat(String(k === 'qty' ? v : updated.qty)) || 0;
|
||||||
|
const cf = parseFloat(String(k === 'conversion_factor' ? v : updated.conversion_factor)) || 1;
|
||||||
|
updated.stock_qty = parseFloat((qty * cf).toFixed(4));
|
||||||
|
}
|
||||||
|
items[idx] = updated;
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleItemCode = async (idx: number, code: string) => {
|
||||||
|
updateItem(idx, 'item_code', code);
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/resource/Item/${encodeURIComponent(code)}`, { credentials: 'include' });
|
||||||
|
const body = await r.json(); const d = body.data; if (!d) return;
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
items[idx] = {
|
||||||
|
...items[idx],
|
||||||
|
item_code: code,
|
||||||
|
item_name: d.item_name || code,
|
||||||
|
description: d.description || d.item_name || code,
|
||||||
|
stock_uom: d.stock_uom || '',
|
||||||
|
uom: d.purchase_uom || d.stock_uom || '',
|
||||||
|
price_list_rate: d.standard_rate ?? 0,
|
||||||
|
rate: items[idx].rate || d.standard_rate || 0,
|
||||||
|
};
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = (afterIdx?: number) => {
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
const newItem = emptyItem();
|
||||||
|
let newIdx: number;
|
||||||
|
if (afterIdx !== undefined) { items.splice(afterIdx + 1, 0, newItem); newIdx = afterIdx + 1; }
|
||||||
|
else { items.push(newItem); newIdx = items.length - 1; }
|
||||||
|
setTimeout(() => setExpandedItem(newIdx), 0);
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeItem = (idx: number) => { setForm(f => { const items = [...(f.items || [])]; items.splice(idx, 1); return { ...f, items }; }); setExpandedItem(null); };
|
||||||
|
|
||||||
|
// ── Tax helpers ───────────────────────────────────────────────────────────
|
||||||
|
const updateTax = (idx: number, k: keyof PurchaseTaxCharge, v: any) =>
|
||||||
|
setForm(f => { const taxes = [...(f.taxes || [])]; taxes[idx] = { ...taxes[idx], [k]: v }; return { ...f, taxes }; });
|
||||||
|
|
||||||
|
const addTax = (afterIdx?: number) => {
|
||||||
|
setForm(f => {
|
||||||
|
const taxes = [...(f.taxes || [])];
|
||||||
|
const newTax = emptyTax();
|
||||||
|
let newIdx: number;
|
||||||
|
if (afterIdx !== undefined) { taxes.splice(afterIdx + 1, 0, newTax); newIdx = afterIdx + 1; }
|
||||||
|
else { taxes.push(newTax); newIdx = taxes.length - 1; }
|
||||||
|
setTimeout(() => setExpandedTax(newIdx), 0);
|
||||||
|
return { ...f, taxes };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeTax = (idx: number) => { setForm(f => { const taxes = [...(f.taxes || [])]; taxes.splice(idx, 1); return { ...f, taxes }; }); setExpandedTax(null); };
|
||||||
|
|
||||||
|
// ── Computed ──────────────────────────────────────────────────────────────
|
||||||
|
const netTotal = (form.items || []).reduce((s, it) => s + ((it.qty || 0) * (it.rate || 0)), 0);
|
||||||
|
const taxTotal = (form.taxes || []).reduce((s, tx) => {
|
||||||
|
if (tx.charge_type === 'On Net Total') return s + ((tx.rate || 0) / 100) * netTotal;
|
||||||
|
return s + (tx.tax_amount || 0);
|
||||||
|
}, 0);
|
||||||
|
const grandTotal = netTotal + taxTotal;
|
||||||
|
|
||||||
|
// ── Payload ───────────────────────────────────────────────────────────────
|
||||||
|
const loadTaxTemplate = async (templateName: string) => {
|
||||||
|
if (!templateName) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/resource/Purchase Taxes and Charges Template/${encodeURIComponent(templateName)}`, { credentials: 'include' });
|
||||||
|
const body = await r.json();
|
||||||
|
const tmpl = body.data;
|
||||||
|
if (tmpl?.taxes?.length) {
|
||||||
|
setForm(f => ({ ...f, taxes: tmpl.taxes.map((tx: any) => ({
|
||||||
|
charge_type: tx.charge_type, account_head: tx.account_head,
|
||||||
|
description: tx.description, rate: tx.rate,
|
||||||
|
cost_center: tx.cost_center, account_currency: tx.account_currency,
|
||||||
|
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||||
|
})) }));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPayload = (): Partial<PurchaseOrder> => ({
|
||||||
|
supplier: form.supplier, company: form.company || undefined,
|
||||||
|
project: form.project || undefined, cost_center: form.cost_center || undefined,
|
||||||
|
transaction_date: form.transaction_date,
|
||||||
|
schedule_date: form.schedule_date || undefined,
|
||||||
|
currency: form.currency || undefined,
|
||||||
|
set_warehouse: form.set_warehouse || undefined,
|
||||||
|
tax_category: (form as any).tax_category || undefined,
|
||||||
|
taxes_and_charges: (form as any).taxes_and_charges || undefined,
|
||||||
|
items: (form.items || []).filter(it => it.item_code).map((it, i) => ({
|
||||||
|
item_code: it.item_code, item_name: it.item_name || it.item_code,
|
||||||
|
description: it.description || it.item_name || it.item_code,
|
||||||
|
qty: it.qty ?? 1, uom: it.uom || undefined, stock_uom: it.stock_uom || undefined,
|
||||||
|
conversion_factor: it.conversion_factor ?? 1,
|
||||||
|
rate: it.rate ?? 0, amount: (it.qty || 0) * (it.rate || 0),
|
||||||
|
price_list_rate: it.price_list_rate ?? 0,
|
||||||
|
schedule_date: it.schedule_date || form.schedule_date || undefined,
|
||||||
|
is_free_item: it.is_free_item ?? 0,
|
||||||
|
warehouse: it.warehouse || form.set_warehouse || undefined,
|
||||||
|
expense_account: it.expense_account || undefined,
|
||||||
|
against_blanket_order: it.against_blanket_order ?? 0,
|
||||||
|
weight_per_unit: it.weight_per_unit || undefined,
|
||||||
|
project: it.project || form.project || undefined,
|
||||||
|
cost_center: it.cost_center || form.cost_center || undefined,
|
||||||
|
idx: i + 1,
|
||||||
|
})),
|
||||||
|
taxes: (form.taxes || []).filter(tx => tx.charge_type).map((tx, i) => ({
|
||||||
|
charge_type: tx.charge_type, account_head: tx.account_head || undefined,
|
||||||
|
description: tx.description || undefined,
|
||||||
|
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||||
|
cost_center: tx.cost_center || undefined, rate: tx.rate ?? 0,
|
||||||
|
account_currency: tx.account_currency || DEFAULT_CURRENCY, idx: i + 1,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.supplier) { toast.error('Supplier is required'); return; }
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
if (isNew) {
|
||||||
|
const created = await purchaseOrderService.createPurchaseOrder(buildPayload());
|
||||||
|
toast.success('Purchase Order created');
|
||||||
|
setIsEditing(false);
|
||||||
|
navigate(`/purchase-orders/${created.name}`);
|
||||||
|
} else {
|
||||||
|
const updated = await purchaseOrderService.updatePurchaseOrder(poName!, buildPayload());
|
||||||
|
setDoc(updated); syncForm(updated);
|
||||||
|
toast.success('Purchase Order saved');
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Error saving'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!poName || isNew) return;
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const updated = await purchaseOrderService.submitPurchaseOrder(poName);
|
||||||
|
setDoc(updated); syncForm(updated);
|
||||||
|
toast.success('Purchase Order submitted');
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Error submitting'); }
|
||||||
|
finally { setSubmitting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPR = () => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('po', poName!);
|
||||||
|
if (form.supplier) p.set('supplier', form.supplier);
|
||||||
|
if (form.company) p.set('company', String(form.company));
|
||||||
|
if (form.project) p.set('project', String(form.project));
|
||||||
|
navigate(`/purchase-receipts/new?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editable = isNew || isEditing;
|
||||||
|
const isSubmitted = !isNew && doc?.docstatus === 1;
|
||||||
|
const title = isNew ? 'New Purchase Order' : (form.supplier_name || poName || '');
|
||||||
|
|
||||||
|
if (loading) return <div className="flex items-center justify-center min-h-[400px]"><FaSpinner className="animate-spin text-amber-500 text-3xl" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3500} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500">
|
||||||
|
<button onClick={() => navigate('/purchase-orders')} className="hover:text-amber-600">Purchase Orders</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Purchase Order' : poName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/purchase-orders')} className="text-gray-400 hover:text-gray-700"><FaArrowLeft /></button>
|
||||||
|
<FaShoppingBag className="text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{title}</h1>
|
||||||
|
{!isNew && poName && (
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{poName}</span>
|
||||||
|
)}
|
||||||
|
{!isNew && (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${(() => { const s = doc?.status || ''; if (doc?.docstatus === 2 || s === 'Cancelled') return 'bg-red-100 text-red-700'; if (!doc || doc.docstatus === 0) return 'bg-yellow-100 text-yellow-800'; if (s === 'Completed') return 'bg-green-100 text-green-800'; if (s === 'To Receive and Bill' || s === 'To Bill' || s === 'To Receive') return 'bg-blue-100 text-blue-800'; if (s === 'Closed') return 'bg-gray-100 text-gray-700'; return 'bg-green-100 text-green-800'; })()}`}>
|
||||||
|
{doc?.docstatus === 2 ? 'Cancelled' : doc?.docstatus === 0 ? 'Draft' : (doc?.status || 'Submitted')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{isSubmitted && (
|
||||||
|
<CreateDropdown items={[
|
||||||
|
{ label: 'Purchase Receipt', icon: <FaFileInvoice size={13} />, onClick: createPR },
|
||||||
|
]} />
|
||||||
|
)}
|
||||||
|
{!isNew && !isEditing && doc?.docstatus === 0 && (
|
||||||
|
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
|
||||||
|
{submitting ? <FaSpinner className="animate-spin" /> : <FaPaperPlane size={12} />} Submit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isNew && !isEditing && !isSubmitted && (
|
||||||
|
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-amber-500 text-amber-600 rounded-lg hover:bg-amber-50 text-sm">
|
||||||
|
<FaEdit /> Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 text-sm font-medium">
|
||||||
|
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{!isNew && <button onClick={() => { if (doc) syncForm(doc); setIsEditing(false); }} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-600 text-sm"><FaTimes /></button>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main fields */}
|
||||||
|
<div className="px-6 pt-5 pb-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||||
|
<div><FL required>Supplier</FL>
|
||||||
|
{editable ? <LinkField label="Supplier" hideLabel doctype="Supplier" value={form.supplier || ''} onChange={v => { set('supplier', v); set('supplier_name', v); }} placeholder="Select supplier…" /> : <RV>{form.supplier_name || form.supplier}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL required>Transaction Date</FL>
|
||||||
|
{editable ? <input type="date" value={form.transaction_date || ''} onChange={e => set('transaction_date', e.target.value)} className={inputCls} /> : <RV>{form.transaction_date}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Schedule Date</FL>
|
||||||
|
{editable ? <input type="date" value={form.schedule_date || ''} onChange={e => set('schedule_date', e.target.value)} className={inputCls} /> : <RV>{form.schedule_date}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Company</FL>
|
||||||
|
{editable ? <LinkField label="Company" hideLabel doctype="Company" value={form.company || ''} onChange={v => set('company', v)} placeholder="Select company…" /> : <RV>{form.company}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Project</FL>
|
||||||
|
{editable ? <LinkField label="Project" hideLabel doctype="Project" value={form.project || ''} onChange={v => set('project', v)} placeholder="Select project…" /> : <RV>{form.project}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Set Warehouse</FL>
|
||||||
|
{editable ? <LinkField label="Warehouse" hideLabel doctype="Warehouse" value={form.set_warehouse || ''} onChange={v => set('set_warehouse', v)} placeholder="Set warehouse…" /> : <RV>{form.set_warehouse}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Currency</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={form.currency || DEFAULT_CURRENCY} onChange={e => set('currency', e.target.value)} className={inputCls}>
|
||||||
|
<option value="SAR">SAR</option><option value="USD">USD</option><option value="EUR">EUR</option>
|
||||||
|
</select>
|
||||||
|
: <RV>{form.currency}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Cost Center</FL>
|
||||||
|
{editable ? <LinkField label="Cost Center" hideLabel doctype="Cost Center" value={form.cost_center || ''} onChange={v => set('cost_center', v)} placeholder="Cost center…" /> : <RV>{form.cost_center}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Items ── */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Items</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-5">
|
||||||
|
<div className="overflow-x-auto -mx-2 mt-3">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Item Code <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-32">Schedule Date</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Qty <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Rate ({form.currency || DEFAULT_CURRENCY})</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount ({form.currency || DEFAULT_CURRENCY})</th>
|
||||||
|
{editable && <th className="w-16 py-2 px-2" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(form.items || []).map((it, idx) => (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedItem === idx ? 'bg-amber-50/60 dark:bg-amber-900/10' : ''}`}>
|
||||||
|
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||||
|
<td className="py-1.5 px-2 min-w-[180px]">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Item" hideLabel doctype="Item" value={it.item_code || ''} onChange={v => handleItemCode(idx, v)} placeholder="Item Code" />
|
||||||
|
: <span className="font-medium text-gray-800 dark:text-gray-200">{it.item_code || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-32">
|
||||||
|
{editable
|
||||||
|
? <input type="date" value={it.schedule_date || ''} onChange={e => updateItem(idx, 'schedule_date', e.target.value)} className={inlineTxt} />
|
||||||
|
: <span className="text-gray-500 text-sm">{it.schedule_date || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-24">
|
||||||
|
{editable ? <input type="number" min={0} step="1" value={it.qty ?? 0} onChange={e => updateItem(idx, 'qty', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{it.qty ?? 0}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-28">
|
||||||
|
{editable ? <input type="number" min={0} step="0.01" value={it.rate ?? 0} onChange={e => updateItem(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} /> : <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{(it.rate ?? 0).toFixed(2)}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{((it.qty || 0) * (it.rate || 0)).toFixed(2)}</td>
|
||||||
|
{editable && (
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setExpandedItem(expandedItem === idx ? null : idx)}
|
||||||
|
className={`p-1.5 rounded text-xs ${expandedItem === idx ? 'bg-amber-500 text-white' : 'text-amber-600 hover:bg-amber-50'}`} title="More fields">
|
||||||
|
<FaPencilAlt size={11} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => removeItem(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{editable && expandedItem === idx && (
|
||||||
|
<POItemRowEditor item={it} rowNo={idx + 1}
|
||||||
|
onChange={(k, v) => { if (k === 'item_code') handleItemCode(idx, v as string); else updateItem(idx, k, v); }}
|
||||||
|
onClose={() => setExpandedItem(null)} onDelete={() => removeItem(idx)} onInsertBelow={() => addItem(idx)} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{editable && (
|
||||||
|
<tr><td colSpan={7} className="py-2 px-3">
|
||||||
|
<button onClick={() => addItem()} className="flex items-center gap-1.5 text-amber-600 hover:text-amber-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-between text-sm border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||||
|
<span className="text-gray-500">Total Qty: <strong>{(form.items || []).reduce((s, it) => s + (it.qty || 0), 0)}</strong></span>
|
||||||
|
<span className="text-gray-500">Net Total: <strong className="text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {netTotal.toFixed(2)}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Taxes ── */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Taxes and Charges</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pt-4 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||||
|
<div><FL>Tax Category</FL>
|
||||||
|
{editable ? <LinkField label="Tax Category" hideLabel doctype="Tax Category" value={(form as any).tax_category || ''} onChange={v => set('tax_category' as any, v)} placeholder="Select tax category…" /> : <RV>{(form as any).tax_category}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Purchase Taxes and Charges Template</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Purchase Taxes and Charges Template" hideLabel doctype="Purchase Taxes and Charges Template" value={(form as any).taxes_and_charges || ''} onChange={v => { set('taxes_and_charges' as any, v); loadTaxTemplate(v); }} placeholder="Select template…" />
|
||||||
|
: <RV>{(form as any).taxes_and_charges}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-5">
|
||||||
|
<div className="overflow-x-auto -mx-2 mt-3">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-44">Type <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3">Account Head <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Tax Rate</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Total</th>
|
||||||
|
{editable && <th className="w-16 py-2 px-2" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(form.taxes || []).map((tx, idx) => {
|
||||||
|
const calcAmt = tx.charge_type === 'On Net Total' ? ((tx.rate || 0) / 100) * netTotal : (tx.charge_type === 'Actual' ? (tx.tax_amount || 0) : ((tx.rate || 0) / 100) * netTotal);
|
||||||
|
const calcRunning = netTotal + (form.taxes || []).slice(0, idx + 1).reduce((s: number, t: any) => {
|
||||||
|
const a = t.charge_type === 'On Net Total' ? ((t.rate || 0) / 100) * netTotal : (t.charge_type === 'Actual' ? (t.tax_amount || 0) : ((t.rate || 0) / 100) * netTotal);
|
||||||
|
return s + a;
|
||||||
|
}, 0);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedTax === idx ? 'bg-amber-50/60 dark:bg-amber-900/10' : ''}`}>
|
||||||
|
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||||
|
<td className="py-1.5 px-2 w-44">
|
||||||
|
{editable
|
||||||
|
? <select value={tx.charge_type || ''} onChange={e => updateTax(idx, 'charge_type', e.target.value)} className={inlineTxt}>
|
||||||
|
<option value="">Select type…</option>
|
||||||
|
<option value="Actual">Actual</option>
|
||||||
|
<option value="On Net Total">On Net Total</option>
|
||||||
|
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||||
|
</select>
|
||||||
|
: <span className="text-gray-700 dark:text-gray-300">{tx.charge_type || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Account Head" hideLabel doctype="Account" value={tx.account_head || ''} onChange={v => updateTax(idx, 'account_head', v)} placeholder="Account Head" />
|
||||||
|
: <span className="text-gray-700 dark:text-gray-300">{tx.account_head || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-24">
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="0.01" value={tx.rate ?? 0} onChange={e => updateTax(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||||
|
: <span className="block text-right text-gray-700 dark:text-gray-300 pr-1">{tx.rate ?? 0}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-3 text-right text-gray-700 dark:text-gray-300 text-sm">{calcAmt.toFixed(2)}</td>
|
||||||
|
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{calcRunning.toFixed(2)}</td>
|
||||||
|
{editable && (
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setExpandedTax(expandedTax === idx ? null : idx)}
|
||||||
|
className={`p-1.5 rounded text-xs ${expandedTax === idx ? 'bg-amber-500 text-white' : 'text-amber-600 hover:bg-amber-50'}`} title="More fields">
|
||||||
|
<FaPencilAlt size={11} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => removeTax(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{editable && expandedTax === idx && (
|
||||||
|
<POTaxRowEditor tax={tx} rowNo={idx + 1} onChange={(k, v) => updateTax(idx, k, v)}
|
||||||
|
onClose={() => setExpandedTax(null)} onDelete={() => removeTax(idx)} onInsertBelow={() => addTax(idx)} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{editable && (
|
||||||
|
<tr><td colSpan={7} className="py-2 px-3">
|
||||||
|
<button onClick={() => addTax()} className="flex items-center gap-1.5 text-amber-600 hover:text-amber-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{(form.taxes || []).length > 0 && (
|
||||||
|
<div className="mt-2 flex justify-end text-sm text-gray-500 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
Total Taxes and Charges: <strong className="ml-2 text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {taxTotal.toFixed(2)}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Totals ── */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Totals</h3>
|
||||||
|
<div className="space-y-2 max-w-xs ml-auto">
|
||||||
|
{[
|
||||||
|
{ label: 'Net Total', value: (doc?.net_total ?? netTotal).toFixed(2) },
|
||||||
|
{ label: 'Total Taxes', value: (doc?.total_taxes_and_charges ?? taxTotal).toFixed(2) },
|
||||||
|
{ label: 'Grand Total', value: (doc?.grand_total ?? grandTotal).toFixed(2) },
|
||||||
|
{ label: 'Rounded Total', value: (doc?.rounded_total ?? grandTotal).toFixed(2) },
|
||||||
|
{ label: 'Advance Paid', value: (doc?.advance_paid ?? 0).toFixed(2) },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="flex justify-between text-sm border-b border-gray-100 dark:border-gray-700 pb-1.5 last:border-0">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
{!isNew && doc && (
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4 grid grid-cols-3 gap-4 text-sm bg-gray-50 dark:bg-gray-900/20">
|
||||||
|
<div><FL>Created By</FL><RV>{doc.owner}</RV></div>
|
||||||
|
<div><FL>Created</FL><RV>{doc.creation ? new Date(doc.creation).toLocaleString() : '-'}</RV></div>
|
||||||
|
<div><FL>Modified</FL><RV>{doc.modified ? new Date(doc.modified).toLocaleString() : '-'}</RV></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Purchase Order"
|
||||||
|
docname={doc?.name || poName || ''}
|
||||||
|
creationDate={doc?.creation}
|
||||||
|
createdBy={doc?.owner}
|
||||||
|
compact={false}
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchaseOrderDetail;
|
||||||
263
pm_app/src/pages/PurchaseOrderList.tsx
Normal file
263
pm_app/src/pages/PurchaseOrderList.tsx
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaShoppingBag, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport } from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import purchaseOrderService, { PurchaseOrder } from '../services/purchaseOrderService';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function getStatusStyle(po: PurchaseOrder) {
|
||||||
|
if (po.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||||
|
if (po.docstatus === 1) {
|
||||||
|
if (po.status === 'Completed') return 'bg-green-100 text-green-700';
|
||||||
|
if (po.status === 'Stopped') return 'bg-red-100 text-red-700';
|
||||||
|
return 'bg-green-100 text-green-700';
|
||||||
|
}
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(po: PurchaseOrder) {
|
||||||
|
if (po.docstatus === 2) return 'Cancelled';
|
||||||
|
if (po.docstatus === 1) return po.status || 'Submitted';
|
||||||
|
return 'Draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseOrderExportFilters(f: { search: string; status: string }) {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (f.search) filters.push(['Purchase Order', 'name', 'like', `%${f.search}%`]);
|
||||||
|
if (f.status === 'Draft') filters.push(['Purchase Order', 'docstatus', '=', 0]);
|
||||||
|
if (f.status === 'Submitted') filters.push(['Purchase Order', 'docstatus', '=', 1]);
|
||||||
|
if (f.status === 'Cancelled') filters.push(['Purchase Order', 'docstatus', '=', 2]);
|
||||||
|
if (f.status === 'Completed') filters.push(['Purchase Order', 'status', '=', 'Completed']);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PurchaseOrderList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [orders, setOrders] = useState<PurchaseOrder[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [applied, setApplied] = useState({ search: '', status: '' });
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters = buildPurchaseOrderExportFilters(f);
|
||||||
|
const [rows, cnt] = await Promise.all([
|
||||||
|
purchaseOrderService.getPurchaseOrders({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||||
|
purchaseOrderService.getPurchaseOrderCount(filters),
|
||||||
|
]);
|
||||||
|
setOrders(rows);
|
||||||
|
setTotal(cnt);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Failed to load');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${page}|${applied.search}|${applied.status}`,
|
||||||
|
[page, applied.search, applied.status],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(orders, selectionResetKey);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const f = { search: searchQuery, status: statusFilter };
|
||||||
|
setApplied(f); setPage(0);
|
||||||
|
};
|
||||||
|
const clear = () => { setSearchQuery(''); setStatusFilter(''); setApplied({ search: '', status: '' }); setPage(0); };
|
||||||
|
const hasActive = !!(applied.search || applied.status);
|
||||||
|
|
||||||
|
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() =>
|
||||||
|
fetchAllRowsForExport({
|
||||||
|
doctype: 'Purchase Order',
|
||||||
|
filters: buildPurchaseOrderExportFilters(applied),
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
}),
|
||||||
|
[applied],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-amber-500 flex items-center justify-center">
|
||||||
|
<FaShoppingBag className="text-white text-base" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Purchase Orders</h1>
|
||||||
|
<p className="text-xs text-gray-500">{total} total</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-amber-600 border border-gray-200 rounded-lg">
|
||||||
|
<FaSync size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={total === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/purchase-orders/new')} className="flex items-center gap-2 px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 text-sm font-medium">
|
||||||
|
<FaPlus size={11} /> New Purchase Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Purchase Order"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={orders.length}
|
||||||
|
totalCount={total}
|
||||||
|
pageData={orders}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="purchase_orders"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||||
|
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-indigo-600 to-indigo-700 text-white">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<FaSearch size={12} /> Filters
|
||||||
|
{hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}
|
||||||
|
</div>
|
||||||
|
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasActive && (
|
||||||
|
<div className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/20 flex flex-wrap gap-2 items-center border-b border-indigo-100 dark:border-indigo-800">
|
||||||
|
{applied.search && <span className="flex items-center gap-1 text-xs bg-indigo-100 dark:bg-indigo-800 text-indigo-700 dark:text-indigo-300 px-2 py-1 rounded-full">ID: {applied.search}<button onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
{applied.status && <span className="flex items-center gap-1 text-xs bg-indigo-100 dark:bg-indigo-800 text-indigo-700 dark:text-indigo-300 px-2 py-1 rounded-full">Status: {applied.status}<button onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
<button onClick={clear} className="text-xs text-indigo-600 hover:underline ml-auto">Clear All</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Order ID</label>
|
||||||
|
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && apply()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Submitted">Submitted</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
<option value="Completed">Completed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<button onClick={apply} className="px-4 py-2 bg-indigo-600 text-white text-sm rounded hover:bg-indigo-700">Apply</button>
|
||||||
|
<button onClick={clear} className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded hover:bg-gray-50">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="w-10 px-2 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500"
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
ref={el => {
|
||||||
|
if (el) el.indeterminate = someOnPageSelected;
|
||||||
|
}}
|
||||||
|
onChange={toggleAllOnPage}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">PO ID</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Supplier</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Transaction Date</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Schedule Date</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Grand Total</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Company</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={8} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} className="text-center py-10 text-gray-400">No purchase orders found</td></tr>
|
||||||
|
) : orders.map(po => (
|
||||||
|
<tr key={po.name} onClick={() => navigate(`/purchase-orders/${po.name}`)} className={`cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-900/10 transition-colors ${selectedRows.has(po.name) ? 'bg-amber-50/90 dark:bg-amber-900/20' : ''}`}>
|
||||||
|
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500"
|
||||||
|
checked={selectedRows.has(po.name)}
|
||||||
|
onChange={() => toggleRow(po.name)}
|
||||||
|
aria-label={`Select ${po.name}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-medium text-amber-600">{po.name}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{po.supplier_name || po.supplier || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{po.transaction_date || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{po.schedule_date || '-'}</td>
|
||||||
|
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(po)}`}>{getStatusLabel(po)}</span></td>
|
||||||
|
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">{(po.grand_total ?? 0).toFixed(2)}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{po.company || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||||
|
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchaseOrderList;
|
||||||
915
pm_app/src/pages/PurchaseReceiptDetail.tsx
Normal file
915
pm_app/src/pages/PurchaseReceiptDetail.tsx
Normal file
@ -0,0 +1,915 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FaArrowLeft, FaSave, FaEdit, FaTimes, FaPlus, FaTrash,
|
||||||
|
FaSpinner, FaPaperPlane, FaChevronDown, FaChevronRight, FaPencilAlt,
|
||||||
|
FaClipboardCheck,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import purchaseReceiptService, { PurchaseReceipt, PurchaseReceiptItem, PurchaseTaxCharge } from '../services/purchaseReceiptService';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
import { DEFAULT_COMPANY, DEFAULT_CURRENCY, displayTxnCurrency } from '../constants/orgDefaults';
|
||||||
|
|
||||||
|
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||||
|
const FL: React.FC<{ children: React.ReactNode; required?: boolean }> = ({ children, required }) => (
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
{children}{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
const RV: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-800/60 rounded min-h-[34px] flex items-center">
|
||||||
|
{children || <span className="text-gray-400">-</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const inputCls = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-green-400';
|
||||||
|
const numCls = inputCls + ' text-right';
|
||||||
|
const inlineNum = 'w-full px-2 py-1 text-sm text-right border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-green-400';
|
||||||
|
const inlineTxt = 'w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-green-400';
|
||||||
|
const roVal = 'px-3 py-2 text-sm text-right bg-gray-50 dark:bg-gray-700/60 rounded text-gray-600 dark:text-gray-300';
|
||||||
|
|
||||||
|
// ── Collapsible group inside row editor ───────────────────────────────────────
|
||||||
|
const RGroup: React.FC<{ title: string; children: React.ReactNode; defaultOpen?: boolean }> = ({ title, children, defaultOpen = false }) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-600 mt-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(o => !o)}
|
||||||
|
className="flex items-center gap-2 py-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{open ? <FaChevronDown size={9} /> : <FaChevronRight size={9} />}{title}
|
||||||
|
</button>
|
||||||
|
{open && <div className="mt-2">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── PR Item Row Editor ─────────────────────────────────────────────────────────
|
||||||
|
const PRItemRowEditor: React.FC<{
|
||||||
|
item: Partial<PurchaseReceiptItem>; rowNo: number;
|
||||||
|
onChange: (k: keyof PurchaseReceiptItem, v: any) => void;
|
||||||
|
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
||||||
|
}> = ({ item, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="p-0">
|
||||||
|
<div className="border border-blue-300 dark:border-blue-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
||||||
|
{/* Editor header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-blue-50 dark:bg-blue-900/30 rounded-t-lg border-b border-blue-200 dark:border-blue-700">
|
||||||
|
<span className="text-sm font-semibold text-blue-700 dark:text-blue-300">Editing Row #{rowNo}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600">Insert Below</button>
|
||||||
|
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Item Code + Item Name */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><FL required>Item Code</FL>
|
||||||
|
<LinkField label="Item" hideLabel doctype="Item" value={item.item_code || ''} onChange={v => onChange('item_code', v)} placeholder="Select item…" />
|
||||||
|
</div>
|
||||||
|
<div><FL>Item Name</FL>
|
||||||
|
<input value={item.item_name || ''} onChange={e => onChange('item_name', e.target.value)} className={inputCls} placeholder="Item name…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<RGroup title="Description" defaultOpen={!!(item.description)}>
|
||||||
|
<textarea rows={2} value={item.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Received and Accepted */}
|
||||||
|
<RGroup title="Received and Accepted" defaultOpen>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div><FL required>Received Qty</FL>
|
||||||
|
<input type="number" min={0} step="1" value={item.received_qty ?? 0} onChange={e => onChange('received_qty', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL required>UOM</FL>
|
||||||
|
<LinkField label="UOM" hideLabel doctype="UOM" value={item.uom || ''} onChange={v => onChange('uom', v)} placeholder="UOM…" />
|
||||||
|
</div>
|
||||||
|
<div><FL required>Accepted Qty</FL>
|
||||||
|
<input type="number" min={0} step="1" value={item.qty ?? item.received_qty ?? 0} onChange={e => onChange('qty', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<input type="checkbox" checked={!!(item.retain_sample)} onChange={e => onChange('retain_sample', e.target.checked ? 1 : 0)} className="rounded" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">Retain Sample</span>
|
||||||
|
</div>
|
||||||
|
{item.retain_sample ? (
|
||||||
|
<div><FL>Sample Quantity</FL>
|
||||||
|
<input type="number" min={0} step="0.001" value={item.sample_quantity ?? 0} onChange={e => onChange('sample_quantity', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div><FL>Rejected Qty</FL>
|
||||||
|
<input type="number" min={0} step="0.001" value={item.rejected_qty ?? 0} onChange={e => onChange('rejected_qty', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Rate and Amount */}
|
||||||
|
<RGroup title="Rate and Amount" defaultOpen>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div><FL>Price List Rate</FL>
|
||||||
|
<div className={roVal}>{(item.price_list_rate ?? 0).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div><FL>Price List Rate (Company Currency)</FL>
|
||||||
|
<div className={roVal}>{(item.price_list_rate ?? 0).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div><FL>Last Purchase Rate</FL>
|
||||||
|
<div className={roVal}>{(item.valuation_rate ?? 0).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Discount and Margin */}
|
||||||
|
<RGroup title="Discount and Margin">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div><FL required>Rate</FL>
|
||||||
|
<input type="number" min={0} step="0.01" value={item.rate ?? 0} onChange={e => onChange('rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL>Rate (Company Currency)</FL>
|
||||||
|
<div className={roVal}>{(item.rate ?? 0).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div><FL>Amount</FL>
|
||||||
|
<div className="px-3 py-2 text-sm font-semibold text-right bg-gray-50 dark:bg-gray-700/60 rounded">
|
||||||
|
{((item.qty ?? item.received_qty ?? 0) * (item.rate || 0)).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><FL>Amount (Company Currency)</FL>
|
||||||
|
<div className={roVal}>{((item.qty ?? item.received_qty ?? 0) * (item.rate || 0)).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<input type="checkbox" checked={!!(item.is_free_item)} onChange={e => onChange('is_free_item', e.target.checked ? 1 : 0)} className="rounded" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">Is Free Item</span>
|
||||||
|
</div>
|
||||||
|
<div><FL>Net Rate</FL>
|
||||||
|
<div className={roVal}>{(item.net_rate ?? item.rate ?? 0).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div><FL>Net Amount</FL>
|
||||||
|
<div className={roVal}>{(item.net_amount ?? 0).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Warehouse and Reference */}
|
||||||
|
<RGroup title="Warehouse and Reference" defaultOpen>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><FL>Accepted Warehouse</FL>
|
||||||
|
<LinkField label="Warehouse" hideLabel doctype="Warehouse" value={item.warehouse || ''} onChange={v => onChange('warehouse', v)} placeholder="Warehouse…" />
|
||||||
|
</div>
|
||||||
|
<div><FL>Rejected Warehouse</FL>
|
||||||
|
<LinkField label="Warehouse" hideLabel doctype="Warehouse" value={item.rejected_warehouse || ''} onChange={v => onChange('rejected_warehouse', v)} placeholder="Rejected Warehouse…" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<input type="checkbox" checked={!!(item.allow_zero_valuation_rate)} onChange={e => onChange('allow_zero_valuation_rate', e.target.checked ? 1 : 0)} className="rounded" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">Allow Zero Valuation Rate</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<input type="checkbox" checked={!!(item.from_warehouse)} onChange={e => onChange('from_warehouse', e.target.checked ? 1 : 0)} className="rounded" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">Return Qty from Rejected Warehouse</span>
|
||||||
|
</div>
|
||||||
|
{item.purchase_order && (
|
||||||
|
<div><FL>Purchase Order</FL>
|
||||||
|
<div className={roVal + ' text-left'}>{item.purchase_order}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div><FL>Schedule Date</FL>
|
||||||
|
<input type="date" value={item.schedule_date || ''} onChange={e => onChange('schedule_date', e.target.value)} className={inputCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Serial and Batch No */}
|
||||||
|
<RGroup title="Serial and Batch No">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={!!(item.use_serial_batch_fields)} onChange={e => onChange('use_serial_batch_fields', e.target.checked ? 1 : 0)} className="rounded" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">Use Serial / Batch Fields</span>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Item Weight Details */}
|
||||||
|
<RGroup title="Item Weight Details">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><FL>Weight Per Unit</FL>
|
||||||
|
<input type="number" min={0} step="0.001" value={item.weight_per_unit ?? 0} onChange={e => onChange('weight_per_unit', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL>Total Weight</FL>
|
||||||
|
<div className={roVal}>{(item.total_weight ?? 0).toFixed(3)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Accounting Details */}
|
||||||
|
<RGroup title="Accounting Details">
|
||||||
|
<div><FL>Expense Account</FL>
|
||||||
|
<LinkField label="Expense Account" hideLabel doctype="Account" value={item.expense_account || ''} onChange={v => onChange('expense_account', v)} placeholder="Expense Account…" />
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
{/* Accounting Dimensions */}
|
||||||
|
<RGroup title="Accounting Dimensions">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><FL>Cost Center</FL>
|
||||||
|
<LinkField label="Cost Center" hideLabel doctype="Cost Center" value={item.cost_center || ''} onChange={v => onChange('cost_center', v)} placeholder="Cost center…" />
|
||||||
|
</div>
|
||||||
|
<div><FL>Project</FL>
|
||||||
|
<LinkField label="Project" hideLabel doctype="Project" value={item.project || ''} onChange={v => onChange('project', v)} placeholder="Project…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── PR Tax Row Editor ──────────────────────────────────────────────────────────
|
||||||
|
const PRTaxRowEditor: React.FC<{
|
||||||
|
tax: Partial<PurchaseTaxCharge>; rowNo: number;
|
||||||
|
onChange: (k: keyof PurchaseTaxCharge, v: any) => void;
|
||||||
|
onClose: () => void; onDelete: () => void; onInsertBelow: () => void;
|
||||||
|
}> = ({ tax, rowNo, onChange, onClose, onDelete, onInsertBelow }) => (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="p-0">
|
||||||
|
<div className="border border-blue-300 dark:border-blue-600 rounded-lg mx-2 my-1 bg-white dark:bg-gray-800 shadow-md">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-blue-50 dark:bg-blue-900/30 rounded-t-lg border-b border-blue-200">
|
||||||
|
<span className="text-sm font-semibold text-blue-700 dark:text-blue-300">Editing Row #{rowNo}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={onInsertBelow} className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-600">Insert Below</button>
|
||||||
|
<button onClick={onDelete} className="px-2 py-1 text-xs border border-red-300 rounded text-red-500 hover:bg-red-50">Delete</button>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600"><FaTimes size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><FL required>Type</FL>
|
||||||
|
<select value={tax.charge_type || ''} onChange={e => onChange('charge_type', e.target.value)} className={inputCls}>
|
||||||
|
<option value="">Select type…</option>
|
||||||
|
<option value="Actual">Actual</option>
|
||||||
|
<option value="On Net Total">On Net Total</option>
|
||||||
|
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||||
|
<option value="On Previous Row Total">On Previous Row Total</option>
|
||||||
|
<option value="On Item Quantity">On Item Quantity</option>
|
||||||
|
<option value="Inter Company Transaction">Inter Company Transaction</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><FL>Description</FL>
|
||||||
|
<textarea rows={3} value={tax.description || ''} onChange={e => onChange('description', e.target.value)} className={inputCls} placeholder="Description…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><FL required>Account Head</FL>
|
||||||
|
<LinkField label="Account Head" hideLabel doctype="Account" value={tax.account_head || ''} onChange={v => onChange('account_head', v)} placeholder="Account…" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={!!(tax.included_in_print_rate)} onChange={e => onChange('included_in_print_rate', e.target.checked ? 1 : 0)} className="rounded" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">Is this Tax included in Basic Rate?</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-blue-500 dark:text-blue-400">If checked, the tax amount will be considered as already included in the Print Rate / Print Amount</p>
|
||||||
|
|
||||||
|
<RGroup title="Accounting Dimensions" defaultOpen>
|
||||||
|
<div><FL>Cost Center</FL>
|
||||||
|
<LinkField label="Cost Center" hideLabel doctype="Cost Center" value={tax.cost_center || ''} onChange={v => onChange('cost_center', v)} placeholder="Cost center…" />
|
||||||
|
</div>
|
||||||
|
</RGroup>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><FL required>Tax Rate</FL>
|
||||||
|
<input type="number" min={0} step="0.01" value={tax.rate ?? 0} onChange={e => onChange('rate', parseFloat(e.target.value) || 0)} className={numCls} />
|
||||||
|
</div>
|
||||||
|
<div><FL>Account Currency</FL>
|
||||||
|
<input value={tax.account_currency || DEFAULT_CURRENCY} onChange={e => onChange('account_currency', e.target.value)} className={inputCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────────
|
||||||
|
const emptyItem = (): Partial<PurchaseReceiptItem> => ({
|
||||||
|
item_code: '', item_name: '', received_qty: 0, qty: 0, rejected_qty: 0,
|
||||||
|
rate: 0, amount: 0, uom: '', conversion_factor: 1, is_free_item: 0, retain_sample: 0,
|
||||||
|
});
|
||||||
|
const emptyTax = (): Partial<PurchaseTaxCharge> => ({
|
||||||
|
charge_type: '', account_head: '', rate: 0, account_currency: DEFAULT_CURRENCY, included_in_print_rate: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const PurchaseReceiptDetail: React.FC = () => {
|
||||||
|
const { prName } = useParams<{ prName: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = prName === 'new';
|
||||||
|
|
||||||
|
const contextPO = searchParams.get('po') || '';
|
||||||
|
const contextSupplier = searchParams.get('supplier') || '';
|
||||||
|
const contextCompany = searchParams.get('company') || DEFAULT_COMPANY;
|
||||||
|
const contextProject = searchParams.get('project') || '';
|
||||||
|
|
||||||
|
const [doc, setDoc] = useState<PurchaseReceipt | null>(null);
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(isNew);
|
||||||
|
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||||
|
const [expandedTax, setExpandedTax] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Partial<PurchaseReceipt>>({
|
||||||
|
supplier: contextSupplier, supplier_name: contextSupplier,
|
||||||
|
company: contextCompany, project: contextProject,
|
||||||
|
posting_date: today, currency: DEFAULT_CURRENCY,
|
||||||
|
items: [], taxes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncForm = useCallback((d: PurchaseReceipt) => {
|
||||||
|
setForm({
|
||||||
|
supplier: d.supplier || '', supplier_name: d.supplier_name || d.supplier || '',
|
||||||
|
company: d.company || DEFAULT_COMPANY, project: d.project || '',
|
||||||
|
posting_date: d.posting_date || today, currency: d.currency || DEFAULT_CURRENCY,
|
||||||
|
set_warehouse: d.set_warehouse || '', cost_center: d.cost_center || '',
|
||||||
|
tax_category: (d as any).tax_category || '',
|
||||||
|
taxes_and_charges: (d as any).taxes_and_charges || '',
|
||||||
|
items: d.items || [], taxes: d.taxes || [],
|
||||||
|
});
|
||||||
|
setExpandedItem(null); setExpandedTax(null);
|
||||||
|
}, [today]);
|
||||||
|
|
||||||
|
// Fetch existing doc
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return;
|
||||||
|
setLoading(true);
|
||||||
|
purchaseReceiptService.getPurchaseReceipt(prName!)
|
||||||
|
.then(d => { setDoc(d); syncForm(d); })
|
||||||
|
.catch(e => toast.error(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [prName, isNew, syncForm]);
|
||||||
|
|
||||||
|
// Auto-fetch company default currency for new documents
|
||||||
|
useEffect(() => {
|
||||||
|
const company = form.company;
|
||||||
|
if (!isNew || !company) return;
|
||||||
|
fetch(`/api/resource/Company/${encodeURIComponent(company)}`, { credentials: 'include' })
|
||||||
|
.then(r => r.json()).then(b => {
|
||||||
|
if (b.data?.default_currency) {
|
||||||
|
const cur = displayTxnCurrency(b.data.default_currency);
|
||||||
|
setForm(f => ({ ...f, currency: cur,
|
||||||
|
buying_price_list: (f as any).buying_price_list || 'Standard Buying',
|
||||||
|
price_list_currency: (f as any).price_list_currency || cur,
|
||||||
|
} as any));
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [form.company, isNew]);
|
||||||
|
|
||||||
|
// Pre-fill from PO when creating new
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew || !contextPO) return;
|
||||||
|
fetch(`/api/resource/Purchase Order/${encodeURIComponent(contextPO)}`, { credentials: 'include' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(body => {
|
||||||
|
const po = body.data;
|
||||||
|
if (!po) return;
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
supplier: po.supplier || f.supplier,
|
||||||
|
supplier_name: po.supplier_name || po.supplier || f.supplier_name,
|
||||||
|
company: po.company || f.company,
|
||||||
|
project: po.project || f.project,
|
||||||
|
items: (po.items || []).map((it: any) => ({
|
||||||
|
item_code: it.item_code,
|
||||||
|
item_name: it.item_name,
|
||||||
|
uom: it.uom || it.stock_uom,
|
||||||
|
stock_uom: it.stock_uom,
|
||||||
|
conversion_factor: it.conversion_factor ?? 1,
|
||||||
|
received_qty: it.qty ?? 0,
|
||||||
|
qty: it.qty ?? 0,
|
||||||
|
rejected_qty: 0,
|
||||||
|
rate: it.rate ?? 0,
|
||||||
|
amount: (it.qty ?? 0) * (it.rate ?? 0),
|
||||||
|
warehouse: it.warehouse || '',
|
||||||
|
purchase_order: contextPO,
|
||||||
|
purchase_order_item: it.name,
|
||||||
|
schedule_date: it.schedule_date || '',
|
||||||
|
project: po.project || '',
|
||||||
|
cost_center: it.cost_center || '',
|
||||||
|
expense_account: it.expense_account || '',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => { /* ignore */ });
|
||||||
|
}, [isNew, contextPO]);
|
||||||
|
|
||||||
|
const set = (k: keyof PurchaseReceipt, v: any) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
// ── Item helpers ─────────────────────────────────────────────────────────────
|
||||||
|
const updateItem = (idx: number, k: keyof PurchaseReceiptItem, v: any) =>
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
const updated = { ...items[idx], [k]: v };
|
||||||
|
if (k === 'qty' || k === 'received_qty' || k === 'rate') {
|
||||||
|
const qty = parseFloat(String(k === 'qty' || k === 'received_qty' ? v : (updated.qty ?? updated.received_qty))) || 0;
|
||||||
|
const rate = parseFloat(String(k === 'rate' ? v : updated.rate)) || 0;
|
||||||
|
updated.amount = parseFloat((qty * rate).toFixed(4));
|
||||||
|
}
|
||||||
|
items[idx] = updated;
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleItemCode = async (idx: number, code: string) => {
|
||||||
|
updateItem(idx, 'item_code', code);
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/resource/Item/${encodeURIComponent(code)}`, { credentials: 'include' });
|
||||||
|
const body = await r.json(); const d = body.data; if (!d) return;
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
items[idx] = {
|
||||||
|
...items[idx],
|
||||||
|
item_code: code,
|
||||||
|
item_name: d.item_name || code,
|
||||||
|
description: d.description || d.item_name || code,
|
||||||
|
stock_uom: d.stock_uom || '',
|
||||||
|
uom: d.purchase_uom || d.stock_uom || '',
|
||||||
|
price_list_rate: d.standard_rate ?? 0,
|
||||||
|
rate: items[idx].rate || d.standard_rate || 0,
|
||||||
|
};
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = (afterIdx?: number) => {
|
||||||
|
setForm(f => {
|
||||||
|
const items = [...(f.items || [])];
|
||||||
|
const newItem = emptyItem();
|
||||||
|
let newIdx: number;
|
||||||
|
if (afterIdx !== undefined) { items.splice(afterIdx + 1, 0, newItem); newIdx = afterIdx + 1; }
|
||||||
|
else { items.push(newItem); newIdx = items.length - 1; }
|
||||||
|
setTimeout(() => setExpandedItem(newIdx), 0);
|
||||||
|
return { ...f, items };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeItem = (idx: number) => {
|
||||||
|
setForm(f => { const items = [...(f.items || [])]; items.splice(idx, 1); return { ...f, items }; });
|
||||||
|
setExpandedItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tax helpers ──────────────────────────────────────────────────────────────
|
||||||
|
const updateTax = (idx: number, k: keyof PurchaseTaxCharge, v: any) =>
|
||||||
|
setForm(f => { const taxes = [...(f.taxes || [])]; taxes[idx] = { ...taxes[idx], [k]: v }; return { ...f, taxes }; });
|
||||||
|
|
||||||
|
const addTax = (afterIdx?: number) => {
|
||||||
|
setForm(f => {
|
||||||
|
const taxes = [...(f.taxes || [])];
|
||||||
|
const newTax = emptyTax();
|
||||||
|
let newIdx: number;
|
||||||
|
if (afterIdx !== undefined) { taxes.splice(afterIdx + 1, 0, newTax); newIdx = afterIdx + 1; }
|
||||||
|
else { taxes.push(newTax); newIdx = taxes.length - 1; }
|
||||||
|
setTimeout(() => setExpandedTax(newIdx), 0);
|
||||||
|
return { ...f, taxes };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeTax = (idx: number) => {
|
||||||
|
setForm(f => { const taxes = [...(f.taxes || [])]; taxes.splice(idx, 1); return { ...f, taxes }; });
|
||||||
|
setExpandedTax(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Computed ─────────────────────────────────────────────────────────────────
|
||||||
|
const netTotal = (form.items || []).reduce((s, it) => s + ((it.qty ?? it.received_qty ?? 0) * (it.rate || 0)), 0);
|
||||||
|
const taxTotal = (form.taxes || []).reduce((s, tx) => {
|
||||||
|
if (tx.charge_type === 'On Net Total') return s + ((tx.rate || 0) / 100) * netTotal;
|
||||||
|
if (tx.charge_type === 'Actual') return s + (tx.tax_amount || 0);
|
||||||
|
return s + ((tx.rate || 0) / 100) * netTotal;
|
||||||
|
}, 0);
|
||||||
|
const grandTotal = netTotal + taxTotal;
|
||||||
|
|
||||||
|
// ── Payload ──────────────────────────────────────────────────────────────────
|
||||||
|
const loadTaxTemplate = async (templateName: string) => {
|
||||||
|
if (!templateName) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/resource/Purchase Taxes and Charges Template/${encodeURIComponent(templateName)}`, { credentials: 'include' });
|
||||||
|
const body = await r.json();
|
||||||
|
const tmpl = body.data;
|
||||||
|
if (tmpl?.taxes?.length) {
|
||||||
|
setForm(f => ({ ...f, taxes: tmpl.taxes.map((tx: any) => ({
|
||||||
|
charge_type: tx.charge_type, account_head: tx.account_head,
|
||||||
|
description: tx.description, rate: tx.rate,
|
||||||
|
cost_center: tx.cost_center, account_currency: tx.account_currency,
|
||||||
|
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||||
|
})) }));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPayload = (): Partial<PurchaseReceipt> => ({
|
||||||
|
supplier: form.supplier,
|
||||||
|
company: form.company || undefined,
|
||||||
|
project: form.project || undefined,
|
||||||
|
posting_date: form.posting_date,
|
||||||
|
currency: form.currency || undefined,
|
||||||
|
set_warehouse: form.set_warehouse || undefined,
|
||||||
|
cost_center: form.cost_center || undefined,
|
||||||
|
tax_category: (form as any).tax_category || undefined,
|
||||||
|
taxes_and_charges: (form as any).taxes_and_charges || undefined,
|
||||||
|
items: (form.items || []).filter(it => it.item_code).map((it, i) => ({
|
||||||
|
item_code: it.item_code,
|
||||||
|
item_name: it.item_name || it.item_code,
|
||||||
|
description: it.description || it.item_name || it.item_code,
|
||||||
|
received_qty: it.received_qty ?? 0,
|
||||||
|
qty: it.qty ?? it.received_qty ?? 0,
|
||||||
|
rejected_qty: it.rejected_qty ?? 0,
|
||||||
|
uom: it.uom || undefined,
|
||||||
|
stock_uom: it.stock_uom || undefined,
|
||||||
|
conversion_factor: it.conversion_factor ?? 1,
|
||||||
|
rate: it.rate ?? 0,
|
||||||
|
amount: (it.qty ?? it.received_qty ?? 0) * (it.rate ?? 0),
|
||||||
|
price_list_rate: it.price_list_rate ?? 0,
|
||||||
|
warehouse: it.warehouse || undefined,
|
||||||
|
rejected_warehouse: it.rejected_warehouse || undefined,
|
||||||
|
expense_account: it.expense_account || undefined,
|
||||||
|
cost_center: it.cost_center || undefined,
|
||||||
|
project: it.project || form.project || undefined,
|
||||||
|
purchase_order: it.purchase_order || undefined,
|
||||||
|
purchase_order_item: it.purchase_order_item || undefined,
|
||||||
|
schedule_date: it.schedule_date || undefined,
|
||||||
|
is_free_item: it.is_free_item ?? 0,
|
||||||
|
retain_sample: it.retain_sample ?? 0,
|
||||||
|
sample_quantity: it.sample_quantity || undefined,
|
||||||
|
weight_per_unit: it.weight_per_unit || undefined,
|
||||||
|
idx: i + 1,
|
||||||
|
})),
|
||||||
|
taxes: (form.taxes || []).filter(tx => tx.charge_type).map((tx, i) => ({
|
||||||
|
charge_type: tx.charge_type,
|
||||||
|
account_head: tx.account_head || undefined,
|
||||||
|
description: tx.description || undefined,
|
||||||
|
included_in_print_rate: tx.included_in_print_rate ?? 0,
|
||||||
|
cost_center: tx.cost_center || undefined,
|
||||||
|
rate: tx.rate ?? 0,
|
||||||
|
account_currency: tx.account_currency || DEFAULT_CURRENCY,
|
||||||
|
idx: i + 1,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.supplier) { toast.error('Supplier is required'); return; }
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
if (isNew) {
|
||||||
|
const created = await purchaseReceiptService.createPurchaseReceipt(buildPayload());
|
||||||
|
toast.success('Purchase Receipt created');
|
||||||
|
setIsEditing(false);
|
||||||
|
navigate(`/purchase-receipts/${created.name}`);
|
||||||
|
} else {
|
||||||
|
const updated = await purchaseReceiptService.updatePurchaseReceipt(prName!, buildPayload());
|
||||||
|
setDoc(updated); syncForm(updated);
|
||||||
|
toast.success('Purchase Receipt saved');
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Error saving'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!prName || isNew) return;
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const updated = await purchaseReceiptService.submitPurchaseReceipt(prName);
|
||||||
|
setDoc(updated); syncForm(updated);
|
||||||
|
toast.success('Purchase Receipt submitted');
|
||||||
|
} catch (e: any) { toast.error(e.message || 'Error submitting'); }
|
||||||
|
finally { setSubmitting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const editable = isNew || isEditing;
|
||||||
|
const isSubmitted = !isNew && doc?.docstatus === 1;
|
||||||
|
const title = isNew ? 'New Purchase Receipt' : (form.supplier_name || prName || '');
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<FaSpinner className="animate-spin text-green-500 text-3xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-6 py-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3500} />
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-6 text-gray-500">
|
||||||
|
<button onClick={() => navigate('/purchase-receipts')} className="hover:text-green-600">Purchase Receipts</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{isNew ? 'New Purchase Receipt' : prName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/purchase-receipts')} className="text-gray-400 hover:text-gray-700"><FaArrowLeft /></button>
|
||||||
|
<FaClipboardCheck className="text-green-500" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{title}</h1>
|
||||||
|
{!isNew && prName && (
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{prName}</span>
|
||||||
|
)}
|
||||||
|
{!isNew && (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${(() => { const s = doc?.status || ''; if (doc?.docstatus === 2 || s === 'Cancelled') return 'bg-red-100 text-red-700'; if (!doc || doc.docstatus === 0) return 'bg-yellow-100 text-yellow-800'; if (s === 'Completed') return 'bg-green-100 text-green-800'; if (s === 'Return Issued') return 'bg-orange-100 text-orange-800'; if (s === 'Closed') return 'bg-gray-100 text-gray-700'; return 'bg-green-100 text-green-800'; })()}`}>
|
||||||
|
{doc?.docstatus === 2 ? 'Cancelled' : doc?.docstatus === 0 ? 'Draft' : (doc?.status || 'Submitted')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{!isNew && !isEditing && doc?.docstatus === 0 && (
|
||||||
|
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium">
|
||||||
|
{submitting ? <FaSpinner className="animate-spin" /> : <FaPaperPlane size={12} />} Submit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isNew && !isEditing && !isSubmitted && (
|
||||||
|
<button onClick={() => setIsEditing(true)} className="flex items-center gap-2 px-4 py-2 border border-green-500 text-green-600 rounded-lg hover:bg-green-50 text-sm">
|
||||||
|
<FaEdit /> Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 text-sm font-medium">
|
||||||
|
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{!isNew && <button onClick={() => { if (doc) syncForm(doc); setIsEditing(false); }} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-600 text-sm"><FaTimes /></button>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main fields */}
|
||||||
|
<div className="px-6 pt-5 pb-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||||
|
<div><FL required>Supplier</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Supplier" hideLabel doctype="Supplier" value={form.supplier || ''} onChange={v => { set('supplier', v); set('supplier_name', v); }} placeholder="Select supplier…" />
|
||||||
|
: <RV>{form.supplier_name || form.supplier}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL required>Posting Date</FL>
|
||||||
|
{editable
|
||||||
|
? <input type="date" value={form.posting_date || ''} onChange={e => set('posting_date', e.target.value)} className={inputCls} />
|
||||||
|
: <RV>{form.posting_date}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Company</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Company" hideLabel doctype="Company" value={form.company || ''} onChange={v => set('company', v)} placeholder="Select company…" />
|
||||||
|
: <RV>{form.company}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Project</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Project" hideLabel doctype="Project" value={form.project || ''} onChange={v => set('project', v)} placeholder="Select project…" />
|
||||||
|
: <RV>{form.project}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Set Warehouse</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Warehouse" hideLabel doctype="Warehouse" value={form.set_warehouse || ''} onChange={v => set('set_warehouse', v)} placeholder="Warehouse…" />
|
||||||
|
: <RV>{form.set_warehouse}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Currency</FL>
|
||||||
|
{editable
|
||||||
|
? <select value={form.currency || DEFAULT_CURRENCY} onChange={e => set('currency', e.target.value)} className={inputCls}>
|
||||||
|
<option value="SAR">SAR</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
</select>
|
||||||
|
: <RV>{form.currency}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Items ── */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Items</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-5">
|
||||||
|
<div className="overflow-x-auto -mx-2 mt-3">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[180px]">Item Code <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 min-w-[140px]">Item Name</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Received Qty</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Accepted Qty</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Rate</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount</th>
|
||||||
|
{editable && <th className="w-16 py-2 px-2" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(form.items || []).map((it, idx) => (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedItem === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
||||||
|
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||||
|
<td className="py-1.5 px-2 min-w-[180px]">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Item" hideLabel doctype="Item" value={it.item_code || ''} onChange={v => handleItemCode(idx, v)} placeholder="Item Code" />
|
||||||
|
: <span className="font-medium text-gray-800 dark:text-gray-200">{it.item_code || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 min-w-[140px]">
|
||||||
|
<span className="text-gray-500 text-sm">{it.item_name || '-'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-24">
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="1" value={it.received_qty ?? 0} onChange={e => updateItem(idx, 'received_qty', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||||
|
: <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{(it.received_qty ?? 0).toFixed(3)}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-24">
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="1" value={it.qty ?? it.received_qty ?? 0} onChange={e => updateItem(idx, 'qty', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||||
|
: <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{(it.qty ?? 0).toFixed(3)}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-28">
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="0.01" value={it.rate ?? 0} onChange={e => updateItem(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||||
|
: <span className="block text-right text-gray-700 dark:text-gray-300 text-sm pr-1">{(it.rate ?? 0).toFixed(2)}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">
|
||||||
|
{((it.qty ?? it.received_qty ?? 0) * (it.rate || 0)).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
{editable && (
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setExpandedItem(expandedItem === idx ? null : idx)}
|
||||||
|
className={`p-1.5 rounded text-xs ${expandedItem === idx ? 'bg-blue-600 text-white' : 'text-green-600 hover:bg-green-50'}`} title="More fields">
|
||||||
|
<FaPencilAlt size={11} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => removeItem(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{editable && expandedItem === idx && (
|
||||||
|
<PRItemRowEditor item={it} rowNo={idx + 1}
|
||||||
|
onChange={(k, v) => { if (k === 'item_code') handleItemCode(idx, v as string); else updateItem(idx, k, v); }}
|
||||||
|
onClose={() => setExpandedItem(null)} onDelete={() => removeItem(idx)} onInsertBelow={() => addItem(idx)} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{editable && (
|
||||||
|
<tr><td colSpan={8} className="py-2 px-3">
|
||||||
|
<button onClick={() => addItem()} className="flex items-center gap-1.5 text-green-600 hover:text-green-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-between text-sm border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||||
|
<span className="text-gray-500">Total Qty: <strong>{(form.items || []).reduce((s, it) => s + (it.qty ?? it.received_qty ?? 0), 0).toFixed(3)}</strong></span>
|
||||||
|
<span className="text-gray-500">Total: <strong className="text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {netTotal.toFixed(2)}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Taxes ── */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="px-6 py-3 bg-gray-50 dark:bg-gray-900/20 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Taxes and Charges</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pt-4 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||||
|
<div><FL>Tax Category</FL>
|
||||||
|
{editable ? <LinkField label="Tax Category" hideLabel doctype="Tax Category" value={(form as any).tax_category || ''} onChange={v => set('tax_category' as any, v)} placeholder="Select tax category…" /> : <RV>{(form as any).tax_category}</RV>}
|
||||||
|
</div>
|
||||||
|
<div><FL>Purchase Taxes and Charges Template</FL>
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Purchase Taxes and Charges Template" hideLabel doctype="Purchase Taxes and Charges Template" value={(form as any).taxes_and_charges || ''} onChange={v => { set('taxes_and_charges' as any, v); loadTaxTemplate(v); }} placeholder="Select template…" />
|
||||||
|
: <RV>{(form as any).taxes_and_charges}</RV>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-5">
|
||||||
|
<div className="overflow-x-auto -mx-2 mt-3">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-8">No.</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-44">Type <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-2 px-3">Account Head <span className="text-red-400">*</span></th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-24">Tax Rate</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Amount</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-2 px-3 w-28">Total</th>
|
||||||
|
{editable && <th className="w-16 py-2 px-2" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(form.taxes || []).map((tx, idx) => {
|
||||||
|
const calcAmt = tx.charge_type === 'On Net Total' ? ((tx.rate || 0) / 100) * netTotal : (tx.charge_type === 'Actual' ? (tx.tax_amount || 0) : ((tx.rate || 0) / 100) * netTotal);
|
||||||
|
const calcRunning = netTotal + (form.taxes || []).slice(0, idx + 1).reduce((s: number, t: any) => {
|
||||||
|
const a = t.charge_type === 'On Net Total' ? ((t.rate || 0) / 100) * netTotal : (t.charge_type === 'Actual' ? (t.tax_amount || 0) : ((t.rate || 0) / 100) * netTotal);
|
||||||
|
return s + a;
|
||||||
|
}, 0);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<tr className={`border-b border-gray-100 dark:border-gray-700 align-middle ${expandedTax === idx ? 'bg-blue-50/60 dark:bg-blue-900/10' : ''}`}>
|
||||||
|
<td className="py-1.5 px-3 text-gray-400 text-xs">{idx + 1}</td>
|
||||||
|
<td className="py-1.5 px-2 w-44">
|
||||||
|
{editable
|
||||||
|
? <select value={tx.charge_type || ''} onChange={e => updateTax(idx, 'charge_type', e.target.value)} className={inlineTxt}>
|
||||||
|
<option value="">Select type…</option>
|
||||||
|
<option value="Actual">Actual</option>
|
||||||
|
<option value="On Net Total">On Net Total</option>
|
||||||
|
<option value="On Previous Row Amount">On Previous Row Amount</option>
|
||||||
|
<option value="On Previous Row Total">On Previous Row Total</option>
|
||||||
|
<option value="On Item Quantity">On Item Quantity</option>
|
||||||
|
<option value="Inter Company Transaction">Inter Company Transaction</option>
|
||||||
|
</select>
|
||||||
|
: <span className="text-gray-700 dark:text-gray-300">{tx.charge_type || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
{editable
|
||||||
|
? <LinkField label="Account Head" hideLabel doctype="Account" value={tx.account_head || ''} onChange={v => updateTax(idx, 'account_head', v)} placeholder="Account Head" />
|
||||||
|
: <span className="text-gray-700 dark:text-gray-300">{tx.account_head || '-'}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 w-24">
|
||||||
|
{editable
|
||||||
|
? <input type="number" min={0} step="0.01" value={tx.rate ?? 0} onChange={e => updateTax(idx, 'rate', parseFloat(e.target.value) || 0)} className={inlineNum} />
|
||||||
|
: <span className="block text-right text-gray-700 dark:text-gray-300 pr-1">{tx.rate ?? 0}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-3 text-right text-gray-700 dark:text-gray-300 text-sm">{calcAmt.toFixed(2)}</td>
|
||||||
|
<td className="py-1.5 px-3 text-right font-semibold text-gray-900 dark:text-white text-sm">{calcRunning.toFixed(2)}</td>
|
||||||
|
{editable && (
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setExpandedTax(expandedTax === idx ? null : idx)}
|
||||||
|
className={`p-1.5 rounded text-xs ${expandedTax === idx ? 'bg-blue-600 text-white' : 'text-green-600 hover:bg-green-50'}`} title="More fields">
|
||||||
|
<FaPencilAlt size={11} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => removeTax(idx)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded"><FaTrash size={11} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{editable && expandedTax === idx && (
|
||||||
|
<PRTaxRowEditor tax={tx} rowNo={idx + 1} onChange={(k, v) => updateTax(idx, k, v)}
|
||||||
|
onClose={() => setExpandedTax(null)} onDelete={() => removeTax(idx)} onInsertBelow={() => addTax(idx)} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);})}
|
||||||
|
{editable && (
|
||||||
|
<tr><td colSpan={7} className="py-2 px-3">
|
||||||
|
<button onClick={() => addTax()} className="flex items-center gap-1.5 text-green-600 hover:text-green-700 text-sm font-medium"><FaPlus size={10} /> Add Row</button>
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{(form.taxes || []).length > 0 && (
|
||||||
|
<div className="mt-2 flex justify-end text-sm text-gray-500 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
Total Taxes and Charges: <strong className="ml-2 text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {taxTotal.toFixed(2)}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Totals ── */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Totals</h3>
|
||||||
|
<div className="space-y-2 max-w-xs ml-auto">
|
||||||
|
{[
|
||||||
|
{ label: 'Net Total', value: (doc?.net_total ?? netTotal).toFixed(2) },
|
||||||
|
{ label: 'Total Taxes', value: (doc?.total_taxes_and_charges ?? taxTotal).toFixed(2) },
|
||||||
|
{ label: 'Grand Total', value: (doc?.grand_total ?? grandTotal).toFixed(2) },
|
||||||
|
{ label: 'Rounded Total', value: (doc?.rounded_total ?? grandTotal).toFixed(2) },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} className="flex justify-between text-sm border-b border-gray-100 dark:border-gray-700 pb-1.5 last:border-0">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">{form.currency || DEFAULT_CURRENCY} {value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
{!isNew && doc && (
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700 px-6 py-4 grid grid-cols-3 gap-4 text-sm bg-gray-50 dark:bg-gray-900/20">
|
||||||
|
<div><FL>Created By</FL><RV>{doc.owner}</RV></div>
|
||||||
|
<div><FL>Created</FL><RV>{doc.creation ? new Date(doc.creation).toLocaleString() : '-'}</RV></div>
|
||||||
|
<div><FL>Modified</FL><RV>{doc.modified ? new Date(doc.modified).toLocaleString() : '-'}</RV></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Purchase Receipt"
|
||||||
|
docname={doc?.name || prName || ''}
|
||||||
|
creationDate={doc?.creation}
|
||||||
|
createdBy={doc?.owner}
|
||||||
|
compact={false}
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchaseReceiptDetail;
|
||||||
259
pm_app/src/pages/PurchaseReceiptList.tsx
Normal file
259
pm_app/src/pages/PurchaseReceiptList.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaClipboardCheck, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport } from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import purchaseReceiptService, { PurchaseReceipt } from '../services/purchaseReceiptService';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function buildPurchaseReceiptExportFilters(f: { search: string; status: string }) {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (f.search) filters.push(['Purchase Receipt', 'name', 'like', `%${f.search}%`]);
|
||||||
|
if (f.status === 'Draft') filters.push(['Purchase Receipt', 'docstatus', '=', 0]);
|
||||||
|
if (f.status === 'Submitted') filters.push(['Purchase Receipt', 'docstatus', '=', 1]);
|
||||||
|
if (f.status === 'Cancelled') filters.push(['Purchase Receipt', 'docstatus', '=', 2]);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusStyle(pr: PurchaseReceipt) {
|
||||||
|
if (pr.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||||
|
if (pr.docstatus === 1) return 'bg-green-100 text-green-700';
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(pr: PurchaseReceipt) {
|
||||||
|
if (pr.docstatus === 2) return 'Cancelled';
|
||||||
|
if (pr.docstatus === 1) return pr.status || 'Submitted';
|
||||||
|
return 'Draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PurchaseReceiptList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [receipts, setReceipts] = useState<PurchaseReceipt[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [applied, setApplied] = useState({ search: '', status: '' });
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (f.search) filters.push(['Purchase Receipt', 'name', 'like', `%${f.search}%`]);
|
||||||
|
if (f.status === 'Draft') filters.push(['Purchase Receipt', 'docstatus', '=', 0]);
|
||||||
|
if (f.status === 'Submitted') filters.push(['Purchase Receipt', 'docstatus', '=', 1]);
|
||||||
|
if (f.status === 'Cancelled') filters.push(['Purchase Receipt', 'docstatus', '=', 2]);
|
||||||
|
const [rows, cnt] = await Promise.all([
|
||||||
|
purchaseReceiptService.getPurchaseReceipts({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||||
|
purchaseReceiptService.getPurchaseReceiptCount(filters),
|
||||||
|
]);
|
||||||
|
setReceipts(rows);
|
||||||
|
setTotal(cnt);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Failed to load');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${page}|${applied.search}|${applied.status}`,
|
||||||
|
[page, applied.search, applied.status],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(receipts, selectionResetKey);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const f = { search: searchQuery, status: statusFilter };
|
||||||
|
setApplied(f); setPage(0);
|
||||||
|
};
|
||||||
|
const clear = () => { setSearchQuery(''); setStatusFilter(''); setApplied({ search: '', status: '' }); setPage(0); };
|
||||||
|
const hasActive = !!(applied.search || applied.status);
|
||||||
|
|
||||||
|
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() =>
|
||||||
|
fetchAllRowsForExport({
|
||||||
|
doctype: 'Purchase Receipt',
|
||||||
|
filters: buildPurchaseReceiptExportFilters(applied),
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
}),
|
||||||
|
[applied],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-green-500 flex items-center justify-center">
|
||||||
|
<FaClipboardCheck className="text-white text-base" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Purchase Receipts</h1>
|
||||||
|
<p className="text-xs text-gray-500">{total} total</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-green-600 border border-gray-200 rounded-lg">
|
||||||
|
<FaSync size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={total === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/purchase-receipts/new')} className="flex items-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm font-medium">
|
||||||
|
<FaPlus size={11} /> New Receipt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Purchase Receipt"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={receipts.length}
|
||||||
|
totalCount={total}
|
||||||
|
pageData={receipts}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="purchase_receipts"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||||
|
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<FaSearch size={12} /> Filters
|
||||||
|
{hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}
|
||||||
|
</div>
|
||||||
|
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasActive && (
|
||||||
|
<div className="px-4 py-2 bg-green-50 dark:bg-green-900/20 flex flex-wrap gap-2 items-center border-b border-green-100 dark:border-green-800">
|
||||||
|
{applied.search && <span className="flex items-center gap-1 text-xs bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-300 px-2 py-1 rounded-full">ID: {applied.search}<button onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
{applied.status && <span className="flex items-center gap-1 text-xs bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-300 px-2 py-1 rounded-full">Status: {applied.status}<button onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
<button onClick={clear} className="text-xs text-green-600 hover:underline ml-auto">Clear All</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Receipt ID</label>
|
||||||
|
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && apply()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-green-400">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Submitted">Submitted</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<button onClick={apply} className="px-4 py-2 bg-green-500 text-white text-sm rounded hover:bg-green-600">Apply</button>
|
||||||
|
<button onClick={clear} className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded hover:bg-gray-50">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="w-10 px-2 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-green-600 focus:ring-green-500"
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
ref={el => {
|
||||||
|
if (el) el.indeterminate = someOnPageSelected;
|
||||||
|
}}
|
||||||
|
onChange={toggleAllOnPage}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">PR ID</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Supplier</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Posting Date</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Grand Total</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Company</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={7} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||||
|
) : receipts.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="text-center py-10 text-gray-400">No purchase receipts found</td></tr>
|
||||||
|
) : receipts.map(pr => (
|
||||||
|
<tr key={pr.name} onClick={() => navigate(`/purchase-receipts/${pr.name}`)} className={`cursor-pointer hover:bg-green-50 dark:hover:bg-green-900/10 transition-colors ${selectedRows.has(pr.name) ? 'bg-green-50/90 dark:bg-green-900/20' : ''}`}>
|
||||||
|
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600 text-green-600 focus:ring-green-500"
|
||||||
|
checked={selectedRows.has(pr.name)}
|
||||||
|
onChange={() => toggleRow(pr.name)}
|
||||||
|
aria-label={`Select ${pr.name}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-medium text-green-600">{pr.name}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{pr.supplier_name || pr.supplier || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{pr.posting_date || '-'}</td>
|
||||||
|
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(pr)}`}>{getStatusLabel(pr)}</span></td>
|
||||||
|
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">SAR {(pr.grand_total ?? 0).toFixed(2)}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{pr.company || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||||
|
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchaseReceiptList;
|
||||||
1206
pm_app/src/pages/SalesInvoiceDetail.tsx
Normal file
1206
pm_app/src/pages/SalesInvoiceDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
367
pm_app/src/pages/SalesInvoiceList.tsx
Normal file
367
pm_app/src/pages/SalesInvoiceList.tsx
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaFileInvoiceDollar, FaPlus, FaSearch, FaFilter, FaSync, FaEye, FaChevronDown, FaChevronUp, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||||
|
import salesInvoiceService, { SalesInvoice } from '../services/salesInvoiceService';
|
||||||
|
import ListPagination from '../components/ListPagination';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const getStatusStyle = (s: string, docstatus?: number) => {
|
||||||
|
if (docstatus === 2) return 'bg-red-100 text-red-700';
|
||||||
|
if (docstatus === 1 || s === 'Paid') return 'bg-green-100 text-green-800';
|
||||||
|
if (s === 'Overdue') return 'bg-orange-100 text-orange-800';
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const SalesInvoiceList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const setSearchParamsRef = useRef(setSearchParams);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchParamsRef.current = setSearchParams;
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
|
const currentPage = useMemo(() => {
|
||||||
|
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const setCurrentPage = useCallback((v: number | ((p: number) => number)) => {
|
||||||
|
const next = typeof v === 'function' ? v(currentPage) : v;
|
||||||
|
setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(next)); return n; });
|
||||||
|
}, [currentPage, setSearchParams]);
|
||||||
|
|
||||||
|
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||||
|
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
||||||
|
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || 'creation desc');
|
||||||
|
|
||||||
|
const [invoices, setInvoices] = useState<SalesInvoice[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const didInitUrlSync = useRef(false);
|
||||||
|
const skipInitialSearchUrlSync = useRef(true);
|
||||||
|
const searchDebounceRef = useRef<number | null>(null);
|
||||||
|
const fetchSeqRef = useRef(0);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const f: Record<string, any> = {};
|
||||||
|
if (statusFilter) f.status = statusFilter;
|
||||||
|
if (searchQuery) f.name = ['like', `%${searchQuery}%`];
|
||||||
|
return f;
|
||||||
|
}, [statusFilter, searchQuery]);
|
||||||
|
|
||||||
|
const fetchInvoices = useCallback(async () => {
|
||||||
|
const reqId = ++fetchSeqRef.current;
|
||||||
|
try {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
const [res, count] = await Promise.all([
|
||||||
|
salesInvoiceService.getSalesInvoices({
|
||||||
|
filters: apiFilters,
|
||||||
|
limit_start: (currentPage - 1) * pageSize,
|
||||||
|
limit_page_length: pageSize,
|
||||||
|
order_by: sortBy,
|
||||||
|
}),
|
||||||
|
salesInvoiceService.getSalesInvoiceCount(apiFilters),
|
||||||
|
]);
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setInvoices(res.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
if (reqId !== fetchSeqRef.current) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch invoices');
|
||||||
|
} finally {
|
||||||
|
if (reqId === fetchSeqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiFilters, currentPage, sortBy]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchInvoices(); }, [fetchInvoices]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${currentPage}|${sortBy}|${JSON.stringify(apiFilters)}`,
|
||||||
|
[currentPage, sortBy, apiFilters],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(invoices, selectionResetKey);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() => fetchAllRowsForExport({ doctype: 'Sales Invoice', filters: apiFilters, orderBy: sortBy }),
|
||||||
|
[apiFilters, sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setStatusFilter(''); setSearchQuery(''); setSortBy('creation desc');
|
||||||
|
setSearchParams(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
['status','q','sort_by'].forEach(k => n.delete(k));
|
||||||
|
n.set('page', '1'); return n;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-apply filters (no Apply button)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) {
|
||||||
|
didInitUrlSync.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchParamsRef.current(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
statusFilter ? n.set('status', statusFilter) : n.delete('status');
|
||||||
|
sortBy !== 'creation desc' ? n.set('sort_by', sortBy) : n.delete('sort_by');
|
||||||
|
n.set('page', '1');
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, [statusFilter, sortBy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) return;
|
||||||
|
if (skipInitialSearchUrlSync.current) {
|
||||||
|
skipInitialSearchUrlSync.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
searchDebounceRef.current = window.setTimeout(() => {
|
||||||
|
setSearchParamsRef.current(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
searchQuery ? n.set('q', searchQuery) : n.delete('q');
|
||||||
|
n.set('page', '1');
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, 450);
|
||||||
|
return () => {
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const hasActiveFilters = !!(statusFilter || searchQuery);
|
||||||
|
const handleView = (name: string) => navigate(`/invoices/${encodeURIComponent(name)}`);
|
||||||
|
const handleEdit = (name: string) => navigate(`/invoices/${encodeURIComponent(name)}?edit=1`);
|
||||||
|
const handleDuplicate = (name: string) => navigate(`/invoices/new?duplicate=${encodeURIComponent(name)}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/projects')} className="text-sm text-gray-500 hover:text-indigo-600">Project Management</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<FaFileInvoiceDollar className="text-indigo-500" /> Sales Invoices
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all disabled:opacity-50"
|
||||||
|
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> <span className="font-medium">{t('listPages.export')}</span>
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/invoices/new')} className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
|
||||||
|
<FaPlus /> New Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Sales Invoice"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={invoices.length}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageData={invoices}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="sales_invoices"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<div className="isolate bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-2.5 rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsFilterExpanded(v => !v)} className="text-white hover:bg-white/20 p-1.5 rounded-lg">
|
||||||
|
{isFilterExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||||
|
</button>
|
||||||
|
<FaFilter className="text-white" size={13} />
|
||||||
|
<span className="text-white font-semibold text-sm">Filters</span>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||||
|
{[statusFilter, searchQuery].filter(Boolean).length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex-1 overflow-x-auto mx-2">
|
||||||
|
<div className="flex items-center gap-2 py-0.5">
|
||||||
|
{searchQuery && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">ID:</span> {searchQuery}
|
||||||
|
<button onClick={() => setSearchQuery('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{statusFilter && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Status:</span> {statusFilter}
|
||||||
|
<button onClick={() => setStatusFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{hasActiveFilters && <button onClick={clearFilters} className="text-white/80 hover:text-white text-xs underline">Clear all</button>}
|
||||||
|
<button onClick={fetchInvoices} className="text-white hover:bg-white/20 p-1.5 rounded-lg" title="Refresh">
|
||||||
|
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isFilterExpanded && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 uppercase tracking-wide mb-1">Invoice ID</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||||
|
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search by ID…"
|
||||||
|
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 uppercase tracking-wide mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Submitted">Submitted</option>
|
||||||
|
<option value="Paid">Paid</option>
|
||||||
|
<option value="Unpaid">Unpaid</option>
|
||||||
|
<option value="Overdue">Overdue</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 uppercase tracking-wide mb-1">Sort By</label>
|
||||||
|
<select value={sortBy} onChange={e => setSortBy(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||||
|
<option value="creation desc">Created (newest)</option>
|
||||||
|
<option value="creation asc">Created (oldest)</option>
|
||||||
|
<option value="posting_date desc">Date (newest)</option>
|
||||||
|
<option value="grand_total desc">Amount (highest)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 text-center text-gray-500">Loading…</div>
|
||||||
|
) : invoices.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<FaFileInvoiceDollar className="text-4xl text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500">No sales invoices yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 px-4 py-3 text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllOnPage}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
|
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
>
|
||||||
|
{allOnPageSelected
|
||||||
|
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||||
|
: someOnPageSelected
|
||||||
|
? (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<FaSquare size={18} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-2 h-0.5 bg-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{['Invoice ID', 'Customer', 'Status', 'Date', 'Grand Total', 'Outstanding', ''].map(h => (
|
||||||
|
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<tr key={inv.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(inv.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`} onClick={() => handleView(inv.name)}>
|
||||||
|
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleRow(inv.name)}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
|
aria-label={`Select ${inv.name}`}
|
||||||
|
>
|
||||||
|
{selectedRows.has(inv.name)
|
||||||
|
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white text-sm">{inv.name}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">{inv.customer_name || inv.customer || '-'}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(inv.status || '', inv.docstatus)}`}>
|
||||||
|
{inv.status || 'Draft'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{inv.posting_date || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{inv.currency || ''} {(inv.grand_total ?? 0).toFixed(2)}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{(inv.outstanding_amount ?? 0).toFixed(2)}</td>
|
||||||
|
<td className="px-6 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => handleView(inv.name)} className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 p-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors" title="View"><FaEye /></button>
|
||||||
|
<button onClick={() => handleEdit(inv.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit"><FaEdit /></button>
|
||||||
|
<button onClick={() => handleDuplicate(inv.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate"><FaCopy /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="border-t border-gray-200 px-4 py-3">
|
||||||
|
<ListPagination currentPage={currentPage} totalPages={totalPages} totalCount={totalCount} pageSize={pageSize} onPageChange={setCurrentPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SalesInvoiceList;
|
||||||
1011
pm_app/src/pages/SalesOrderDetail.tsx
Normal file
1011
pm_app/src/pages/SalesOrderDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
339
pm_app/src/pages/SalesOrderList.tsx
Normal file
339
pm_app/src/pages/SalesOrderList.tsx
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaShoppingCart, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport, FaEye, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import salesOrderService, { SalesOrder } from '../services/salesOrderService';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function buildSalesOrderExportFilters(f: { search: string; status: string; project: string }) {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (f.search) filters.push(['Sales Order', 'name', 'like', `%${f.search}%`]);
|
||||||
|
if (f.project) filters.push(['Sales Order', 'project', '=', f.project]);
|
||||||
|
if (f.status === 'Draft') filters.push(['Sales Order', 'docstatus', '=', 0]);
|
||||||
|
if (f.status === 'Submitted') filters.push(['Sales Order', 'docstatus', '=', 1]);
|
||||||
|
if (f.status === 'Cancelled') filters.push(['Sales Order', 'docstatus', '=', 2]);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusStyle(so: SalesOrder) {
|
||||||
|
if (so.docstatus === 2) return 'bg-red-100 text-red-700';
|
||||||
|
if (so.docstatus === 1) {
|
||||||
|
if (so.billing_status === 'Fully Billed') return 'bg-green-100 text-green-700';
|
||||||
|
if (so.delivery_status === 'Fully Delivered') return 'bg-blue-100 text-blue-700';
|
||||||
|
return 'bg-blue-100 text-blue-700';
|
||||||
|
}
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(so: SalesOrder) {
|
||||||
|
if (so.docstatus === 2) return 'Cancelled';
|
||||||
|
if (so.docstatus === 1) return so.status || 'Submitted';
|
||||||
|
return 'Draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SalesOrderList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [orders, setOrders] = useState<SalesOrder[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
|
||||||
|
const initialProject = searchParams.get('project')?.trim() || '';
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [projectFilter, setProjectFilter] = useState(initialProject);
|
||||||
|
const [applied, setApplied] = useState({ search: '', status: '', project: initialProject });
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const didInitUrlSync = useRef(false);
|
||||||
|
const searchDebounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const p = searchParams.get('project')?.trim() || '';
|
||||||
|
setProjectFilter(p);
|
||||||
|
setApplied(a => {
|
||||||
|
if (a.project === p) return a;
|
||||||
|
setPage(0);
|
||||||
|
return { ...a, project: p };
|
||||||
|
});
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const load = useCallback(async (off: number, f: typeof applied) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (f.search) filters.push(['Sales Order', 'name', 'like', `%${f.search}%`]);
|
||||||
|
if (f.project) filters.push(['Sales Order', 'project', '=', f.project]);
|
||||||
|
if (f.status === 'Draft') filters.push(['Sales Order', 'docstatus', '=', 0]);
|
||||||
|
if (f.status === 'Submitted') filters.push(['Sales Order', 'docstatus', '=', 1]);
|
||||||
|
if (f.status === 'Cancelled') filters.push(['Sales Order', 'docstatus', '=', 2]);
|
||||||
|
const [rows, cnt] = await Promise.all([
|
||||||
|
salesOrderService.getSalesOrders({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||||||
|
salesOrderService.getSalesOrderCount(filters),
|
||||||
|
]);
|
||||||
|
setOrders(rows);
|
||||||
|
setTotal(cnt);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Failed to load');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(0, applied); }, [load, applied]);
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${page}|${applied.search}|${applied.status}|${applied.project}`,
|
||||||
|
[page, applied.search, applied.status, applied.project],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(orders, selectionResetKey);
|
||||||
|
|
||||||
|
// Auto-apply filters (matches other lists; no explicit Apply button)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) {
|
||||||
|
didInitUrlSync.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextApplied = { search: applied.search, status: statusFilter, project: projectFilter.trim() };
|
||||||
|
setApplied(nextApplied);
|
||||||
|
setPage(0);
|
||||||
|
setSearchParams(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
if (nextApplied.project) n.set('project', nextApplied.project); else n.delete('project');
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [statusFilter, projectFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) return;
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
searchDebounceRef.current = window.setTimeout(() => {
|
||||||
|
const nextApplied = { search: searchQuery, status: statusFilter, project: projectFilter.trim() };
|
||||||
|
setApplied(nextApplied);
|
||||||
|
setPage(0);
|
||||||
|
}, 450);
|
||||||
|
return () => {
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery, statusFilter, projectFilter]);
|
||||||
|
const clear = () => {
|
||||||
|
setSearchQuery(''); setStatusFilter(''); setProjectFilter('');
|
||||||
|
setApplied({ search: '', status: '', project: '' }); setPage(0);
|
||||||
|
setSearchParams(prev => { const n = new URLSearchParams(prev); n.delete('project'); return n; });
|
||||||
|
};
|
||||||
|
const hasActive = !!(applied.search || applied.status || applied.project);
|
||||||
|
|
||||||
|
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||||||
|
const handleView = (name: string) => navigate(`/sales-orders/${encodeURIComponent(name)}`);
|
||||||
|
const handleEdit = (name: string) => navigate(`/sales-orders/${encodeURIComponent(name)}?edit=1`);
|
||||||
|
const handleDuplicate = (name: string) => navigate(`/sales-orders/new?duplicate=${encodeURIComponent(name)}`);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() =>
|
||||||
|
fetchAllRowsForExport({
|
||||||
|
doctype: 'Sales Order',
|
||||||
|
filters: buildSalesOrderExportFilters(applied),
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
}),
|
||||||
|
[applied],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center">
|
||||||
|
<FaShoppingCart className="text-white text-base" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Sales Orders</h1>
|
||||||
|
<p className="text-xs text-gray-500">{total} total</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-blue-600 border border-gray-200 rounded-lg">
|
||||||
|
<FaSync size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||||||
|
disabled={total === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> {t('listPages.export')}
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const qs = applied.project ? `?project=${encodeURIComponent(applied.project)}` : '';
|
||||||
|
navigate(`/sales-orders/new${qs}`);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<FaPlus size={11} /> New Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Sales Order"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={orders.length}
|
||||||
|
totalCount={total}
|
||||||
|
pageData={orders}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="sales_orders"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||||||
|
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 text-white">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<FaSearch size={12} /> Filters
|
||||||
|
{hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}
|
||||||
|
</div>
|
||||||
|
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasActive && (
|
||||||
|
<div className="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 flex flex-wrap gap-2 items-center border-b border-blue-100 dark:border-blue-800">
|
||||||
|
{applied.search && <span className="flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">ID: {applied.search}<button type="button" onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
{applied.project && <span className="flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">Project: {applied.project}<button type="button" onClick={() => { setProjectFilter(''); setApplied(a => ({ ...a, project: '' })); setSearchParams(prev => { const n = new URLSearchParams(prev); n.delete('project'); return n; }); }}><FaTimes size={9} /></button></span>}
|
||||||
|
{applied.status && <span className="flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">Status: {applied.status}<button type="button" onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||||||
|
<button type="button" onClick={clear} className="text-xs text-blue-600 hover:underline ml-auto">Clear All</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Order ID</label>
|
||||||
|
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Project</label>
|
||||||
|
<input value={projectFilter} onChange={e => setProjectFilter(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Project name…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Submitted">Submitted</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<th className="w-10 px-4 py-3 text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllOnPage}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
>
|
||||||
|
{allOnPageSelected
|
||||||
|
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||||||
|
: someOnPageSelected
|
||||||
|
? (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<FaSquare size={18} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-2 h-0.5 bg-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Order ID</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Customer</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||||||
|
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Grand Total</th>
|
||||||
|
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-28"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={7} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="text-center py-10 text-gray-400">No sales orders found</td></tr>
|
||||||
|
) : orders.map(so => (
|
||||||
|
<tr key={so.name} onClick={() => handleView(so.name)} className={`cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${selectedRows.has(so.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}>
|
||||||
|
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleRow(so.name)}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
aria-label={`Select ${so.name}`}
|
||||||
|
>
|
||||||
|
{selectedRows.has(so.name)
|
||||||
|
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-medium text-gray-900 dark:text-white">{so.name}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{so.customer_name || so.customer || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">{so.transaction_date || '-'}</td>
|
||||||
|
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(so)}`}>{getStatusLabel(so)}</span></td>
|
||||||
|
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">{so.currency || 'SAR'} {(so.grand_total ?? 0).toFixed(2)}</td>
|
||||||
|
<td className="py-2 px-4" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => handleView(so.name)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title="View"><FaEye /></button>
|
||||||
|
<button onClick={() => handleEdit(so.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit"><FaEdit /></button>
|
||||||
|
<button onClick={() => handleDuplicate(so.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate"><FaCopy /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||||||
|
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SalesOrderList;
|
||||||
1024
pm_app/src/pages/TaskDetail.tsx
Normal file
1024
pm_app/src/pages/TaskDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
502
pm_app/src/pages/TaskList.tsx
Normal file
502
pm_app/src/pages/TaskList.tsx
Normal file
@ -0,0 +1,502 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useTaskList } from '../hooks/useProject';
|
||||||
|
import ListPagination from '../components/ListPagination';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import { buildDateRangeFilters } from '../utils/listFilterUtils';
|
||||||
|
import {
|
||||||
|
FaTasks, FaPlus, FaSearch, FaFilter, FaSync, FaEye, FaChevronDown, FaChevronUp, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
import type { Task } from '../services/projectService';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
|
||||||
|
const getStatusStyle = (s: string) => {
|
||||||
|
switch (s?.toLowerCase()) {
|
||||||
|
case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||||
|
case 'working': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300';
|
||||||
|
case 'completed': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
case 'cancelled': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
case 'overdue': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityStyle = (p: string) => {
|
||||||
|
switch (p?.toLowerCase()) {
|
||||||
|
case 'high': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
case 'medium': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
|
||||||
|
case 'low': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TaskList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const currentPage = useMemo(() => {
|
||||||
|
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const setCurrentPage = useCallback((v: number | ((p: number) => number)) => {
|
||||||
|
const next = typeof v === 'function' ? v(currentPage) : v;
|
||||||
|
setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(next)); return n; });
|
||||||
|
}, [currentPage, setSearchParams]);
|
||||||
|
|
||||||
|
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState(searchParams.get('priority') || '');
|
||||||
|
const [projectFilter, setProjectFilter] = useState(searchParams.get('project') || '');
|
||||||
|
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
||||||
|
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(
|
||||||
|
(searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || ''
|
||||||
|
);
|
||||||
|
const [dateStart, setDateStart] = useState(searchParams.get('date_start') || '');
|
||||||
|
const [dateEnd, setDateEnd] = useState(searchParams.get('date_end') || '');
|
||||||
|
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || 'creation desc');
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const didInitUrlSync = useRef(false);
|
||||||
|
const skipInitialSearchUrlSync = useRef(true);
|
||||||
|
const searchDebounceRef = useRef<number | null>(null);
|
||||||
|
const setSearchParamsRef = useRef(setSearchParams);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchParamsRef.current = setSearchParams;
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const f: Record<string, any> = {};
|
||||||
|
if (statusFilter) f.status = statusFilter;
|
||||||
|
if (priorityFilter) f.priority = priorityFilter;
|
||||||
|
if (projectFilter) f.project = projectFilter;
|
||||||
|
if (searchQuery) f.subject = ['like', `%${searchQuery}%`];
|
||||||
|
Object.assign(f, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||||
|
return f;
|
||||||
|
}, [statusFilter, priorityFilter, projectFilter, searchQuery, dateFilterBy, dateStart, dateEnd]);
|
||||||
|
|
||||||
|
const { tasks, loading, error, totalCount, refetch } = useTaskList({
|
||||||
|
filters: apiFilters,
|
||||||
|
limit_start: (currentPage - 1) * pageSize,
|
||||||
|
limit_page_length: pageSize,
|
||||||
|
order_by: sortBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${currentPage}|${sortBy}|${JSON.stringify(apiFilters)}`,
|
||||||
|
[currentPage, sortBy, apiFilters],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(tasks, selectionResetKey);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() => fetchAllRowsForExport({ doctype: 'Task', filters: apiFilters, orderBy: sortBy }),
|
||||||
|
[apiFilters, sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setStatusFilter(''); setPriorityFilter(''); setProjectFilter('');
|
||||||
|
setSearchQuery(''); setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||||
|
setSortBy('creation desc');
|
||||||
|
setSearchParams(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
['status','priority','project','q','date_filter_by','date_start','date_end','sort_by'].forEach(k => n.delete(k));
|
||||||
|
n.set('page','1'); return n;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-sync filters (no "Apply Filters" button for Project Management pages)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) {
|
||||||
|
didInitUrlSync.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchParamsRef.current(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
statusFilter ? n.set('status', statusFilter) : n.delete('status');
|
||||||
|
priorityFilter ? n.set('priority', priorityFilter) : n.delete('priority');
|
||||||
|
projectFilter ? n.set('project', projectFilter) : n.delete('project');
|
||||||
|
dateFilterBy ? n.set('date_filter_by', dateFilterBy) : n.delete('date_filter_by');
|
||||||
|
dateStart ? n.set('date_start', dateStart) : n.delete('date_start');
|
||||||
|
dateEnd ? n.set('date_end', dateEnd) : n.delete('date_end');
|
||||||
|
sortBy !== 'creation desc' ? n.set('sort_by', sortBy) : n.delete('sort_by');
|
||||||
|
n.set('page', '1');
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, [statusFilter, priorityFilter, projectFilter, dateFilterBy, dateStart, dateEnd, sortBy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) return;
|
||||||
|
if (skipInitialSearchUrlSync.current) {
|
||||||
|
skipInitialSearchUrlSync.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
searchDebounceRef.current = window.setTimeout(() => {
|
||||||
|
setSearchParamsRef.current(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
searchQuery ? n.set('q', searchQuery) : n.delete('q');
|
||||||
|
n.set('page', '1');
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, 450);
|
||||||
|
return () => {
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const hasActiveFilters = !!(statusFilter || priorityFilter || projectFilter || searchQuery || (dateFilterBy && (dateStart || dateEnd)));
|
||||||
|
const handleEdit = (taskName: string) => navigate(`/projects/tasks/${encodeURIComponent(taskName)}?edit=1`);
|
||||||
|
const handleDuplicate = (taskName: string) => navigate(`/projects/tasks/new?duplicate=${encodeURIComponent(taskName)}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/projects')}
|
||||||
|
className="text-sm text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
|
||||||
|
>
|
||||||
|
{t('projects.moduleTitle')}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<FaTasks className="text-indigo-500" /> {t('projects.tasksDoctype')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all disabled:opacity-50"
|
||||||
|
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> <span className="font-medium">{t('listPages.export')}</span>
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/projects/tasks/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
<FaPlus /> {t('projects.newTask')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Task"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={tasks.length}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageData={tasks}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="tasks"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Filter Panel ── */}
|
||||||
|
<div className="isolate bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-2.5 rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{/* Left: toggle + title + count */}
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFilterExpanded(v => !v)}
|
||||||
|
className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
{isFilterExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaFilter className="text-white" size={13} />
|
||||||
|
<span className="text-white font-semibold text-sm">Filters</span>
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||||
|
{[searchQuery, statusFilter, priorityFilter, projectFilter, dateFilterBy && dateStart].filter(Boolean).length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: active filter pills */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex-1 overflow-x-auto mx-2">
|
||||||
|
<div className="flex items-center gap-2 py-0.5">
|
||||||
|
{searchQuery && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Subject:</span> {searchQuery}
|
||||||
|
<button onClick={() => setSearchQuery('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{statusFilter && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Status:</span> {statusFilter}
|
||||||
|
<button onClick={() => setStatusFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{priorityFilter && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Priority:</span> {priorityFilter}
|
||||||
|
<button onClick={() => setPriorityFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{projectFilter && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Project:</span> {projectFilter}
|
||||||
|
<button onClick={() => setProjectFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dateFilterBy && dateStart && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">{dateFilterBy === 'creation' ? 'Created' : 'Modified'}:</span> {dateStart}{dateEnd ? ` – ${dateEnd}` : ''}
|
||||||
|
<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: clear + refresh */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={clearFilters} className="text-white/80 hover:text-white text-xs underline whitespace-nowrap">
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => refetch()} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all" title="Refresh">
|
||||||
|
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded filter body */}
|
||||||
|
{isFilterExpanded && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Search</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||||
|
<input
|
||||||
|
type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && e.preventDefault()}
|
||||||
|
placeholder="Search by task subject…"
|
||||||
|
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Working">Working</option>
|
||||||
|
<option value="Completed">Completed</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Priority</label>
|
||||||
|
<select value={priorityFilter} onChange={e => setPriorityFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||||
|
<option value="">All Priority</option>
|
||||||
|
<option value="High">High</option>
|
||||||
|
<option value="Medium">Medium</option>
|
||||||
|
<option value="Low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Project</label>
|
||||||
|
<LinkField
|
||||||
|
label="Project"
|
||||||
|
hideLabel
|
||||||
|
value={projectFilter}
|
||||||
|
onChange={setProjectFilter}
|
||||||
|
doctype="Project"
|
||||||
|
placeholder="Filter by project…"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date filter by */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Date Filter By</label>
|
||||||
|
<select value={dateFilterBy} onChange={e => setDateFilterBy(e.target.value as '' | 'creation' | 'modified')} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="creation">Created</option>
|
||||||
|
<option value="modified">Modified</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dateFilterBy && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">From</label>
|
||||||
|
<input type="date" value={dateStart} onChange={e => setDateStart(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">To</label>
|
||||||
|
<input type="date" value={dateEnd} onChange={e => setDateEnd(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sort by */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Sort By</label>
|
||||||
|
<select value={sortBy} onChange={e => setSortBy(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||||
|
<option value="creation desc">Created (newest)</option>
|
||||||
|
<option value="creation asc">Created (oldest)</option>
|
||||||
|
<option value="exp_end_date asc">Due date (soonest)</option>
|
||||||
|
<option value="priority desc">Priority (high first)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<FaTasks className="text-4xl text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">{t('projects.noTasks')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 px-4 py-3 text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllOnPage}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
|
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
>
|
||||||
|
{allOnPageSelected
|
||||||
|
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||||
|
: someOnPageSelected
|
||||||
|
? (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<FaSquare size={18} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-2 h-0.5 bg-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{[t('projects.taskColumn'), t('projects.project'), t('commonFields.status'), t('commonFields.priority'), t('projects.assignedTo'), t('projects.dueDate'), 'Exp. Time', ''].map(h => (
|
||||||
|
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{tasks.map((task: Task) => (
|
||||||
|
<tr
|
||||||
|
key={task.name}
|
||||||
|
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(task.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`}
|
||||||
|
onClick={() => navigate(`/projects/tasks/${task.name}`)}
|
||||||
|
>
|
||||||
|
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleRow(task.name)}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
|
aria-label={`Select ${task.name}`}
|
||||||
|
>
|
||||||
|
{selectedRows.has(task.name)
|
||||||
|
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{task.subject || task.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{task.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{task.project ? (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); navigate(`/projects/list/${task.project}`); }}
|
||||||
|
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||||
|
>
|
||||||
|
{task.project}
|
||||||
|
</button>
|
||||||
|
) : <span className="text-gray-400 text-sm">-</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(task.status || '')}`}>{task.status || '-'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getPriorityStyle(task.priority || '')}`}>{task.priority || '-'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{task._assign ? (JSON.parse(task._assign)[0] || '-') : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{formatDate(task.exp_end_date || '')}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{task.expected_time ? `${task.expected_time}h` : '-'}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => navigate(`/projects/tasks/${task.name}`)} className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 p-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors" title="View">
|
||||||
|
<FaEye />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleEdit(task.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit">
|
||||||
|
<FaEdit />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDuplicate(task.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate">
|
||||||
|
<FaCopy />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
|
<ListPagination currentPage={currentPage} totalPages={totalPages} totalCount={totalCount} pageSize={pageSize} onPageChange={setCurrentPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskList;
|
||||||
1043
pm_app/src/pages/TimesheetDetail.tsx
Normal file
1043
pm_app/src/pages/TimesheetDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
448
pm_app/src/pages/TimesheetList.tsx
Normal file
448
pm_app/src/pages/TimesheetList.tsx
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useTimesheetList } from '../hooks/useProject';
|
||||||
|
import ListPagination from '../components/ListPagination';
|
||||||
|
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||||
|
import { FaClock, FaPlus, FaSearch, FaFilter, FaSync, FaEye, FaChevronDown, FaChevronUp, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||||||
|
import type { Timesheet } from '../services/projectService';
|
||||||
|
import DynamicExportModal from '../components/DynamicExportModal';
|
||||||
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||||
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||||||
|
|
||||||
|
const getStatusStyle = (s: string) => {
|
||||||
|
switch (s?.toLowerCase()) {
|
||||||
|
case 'submitted': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
case 'draft': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
case 'cancelled': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimesheetList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const setSearchParamsRef = useRef(setSearchParams);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchParamsRef.current = setSearchParams;
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
|
const currentPage = useMemo(() => {
|
||||||
|
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const setCurrentPage = useCallback((v: number | ((p: number) => number)) => {
|
||||||
|
const next = typeof v === 'function' ? v(currentPage) : v;
|
||||||
|
setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(next)); return n; });
|
||||||
|
}, [currentPage, setSearchParams]);
|
||||||
|
|
||||||
|
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||||
|
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
||||||
|
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(
|
||||||
|
(searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || ''
|
||||||
|
);
|
||||||
|
const [dateStart, setDateStart] = useState(searchParams.get('date_start') || '');
|
||||||
|
const [dateEnd, setDateEnd] = useState(searchParams.get('date_end') || '');
|
||||||
|
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || 'creation desc');
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const didInitUrlSync = useRef(false);
|
||||||
|
const skipInitialSearchUrlSync = useRef(true);
|
||||||
|
const searchDebounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const projectFromUrl = useMemo(() => searchParams.get('project')?.trim() || '', [searchParams]);
|
||||||
|
const [projectDraft, setProjectDraft] = useState(projectFromUrl);
|
||||||
|
useEffect(() => { setProjectDraft(projectFromUrl); }, [projectFromUrl]);
|
||||||
|
|
||||||
|
const appendFilters = useMemo(
|
||||||
|
() => (projectFromUrl ? [['Timesheet Detail', 'project', '=', projectFromUrl]] as any[] : []),
|
||||||
|
[projectFromUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const f: Record<string, any> = {};
|
||||||
|
if (statusFilter) f.status = statusFilter;
|
||||||
|
if (searchQuery) f.name = ['like', `%${searchQuery}%`];
|
||||||
|
Object.assign(f, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||||
|
return f;
|
||||||
|
}, [statusFilter, searchQuery, dateFilterBy, dateStart, dateEnd]);
|
||||||
|
|
||||||
|
const { timesheets, loading, error, totalCount, refetch } = useTimesheetList({
|
||||||
|
filters: apiFilters,
|
||||||
|
appendFilters,
|
||||||
|
limit_start: (currentPage - 1) * pageSize,
|
||||||
|
limit_page_length: pageSize,
|
||||||
|
order_by: sortBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionResetKey = useMemo(
|
||||||
|
() => `${currentPage}|${sortBy}|${projectFromUrl}|${JSON.stringify(apiFilters)}|${JSON.stringify(appendFilters)}`,
|
||||||
|
[currentPage, sortBy, projectFromUrl, apiFilters, appendFilters],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
toggleRow,
|
||||||
|
toggleAllOnPage,
|
||||||
|
allOnPageSelected,
|
||||||
|
someOnPageSelected,
|
||||||
|
} = useListPageSelection(timesheets, selectionResetKey);
|
||||||
|
|
||||||
|
const timesheetExportFilters = useMemo(() => {
|
||||||
|
let fa = toFrappeFilterArray(apiFilters);
|
||||||
|
if (appendFilters.length) fa = [...fa, ...appendFilters];
|
||||||
|
return fa.length > 0 ? fa : {};
|
||||||
|
}, [apiFilters, appendFilters]);
|
||||||
|
|
||||||
|
const fetchAllForExport = useCallback(
|
||||||
|
() => fetchAllRowsForExport({ doctype: 'Timesheet', filters: timesheetExportFilters, orderBy: sortBy }),
|
||||||
|
[timesheetExportFilters, sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setStatusFilter(''); setSearchQuery('');
|
||||||
|
setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||||
|
setSortBy('creation desc'); setProjectDraft('');
|
||||||
|
setSearchParams(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
['status','q','date_filter_by','date_start','date_end','sort_by','project'].forEach(k => n.delete(k));
|
||||||
|
n.set('page','1'); return n;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-sync filters (no "Apply Filters" button for Project Management pages)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) {
|
||||||
|
didInitUrlSync.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchParamsRef.current(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
statusFilter ? n.set('status', statusFilter) : n.delete('status');
|
||||||
|
dateFilterBy ? n.set('date_filter_by', dateFilterBy) : n.delete('date_filter_by');
|
||||||
|
dateStart ? n.set('date_start', dateStart) : n.delete('date_start');
|
||||||
|
dateEnd ? n.set('date_end', dateEnd) : n.delete('date_end');
|
||||||
|
sortBy !== 'creation desc' ? n.set('sort_by', sortBy) : n.delete('sort_by');
|
||||||
|
const p = projectDraft.trim();
|
||||||
|
p ? n.set('project', p) : n.delete('project');
|
||||||
|
n.set('page', '1');
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, [statusFilter, dateFilterBy, dateStart, dateEnd, sortBy, projectDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitUrlSync.current) return;
|
||||||
|
if (skipInitialSearchUrlSync.current) {
|
||||||
|
skipInitialSearchUrlSync.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
searchDebounceRef.current = window.setTimeout(() => {
|
||||||
|
setSearchParamsRef.current(prev => {
|
||||||
|
const n = new URLSearchParams(prev);
|
||||||
|
searchQuery ? n.set('q', searchQuery) : n.delete('q');
|
||||||
|
n.set('page', '1');
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, 450);
|
||||||
|
return () => {
|
||||||
|
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const hasActiveFilters = !!(statusFilter || searchQuery || projectFromUrl || (dateFilterBy && (dateStart || dateEnd)));
|
||||||
|
const handleEdit = (timesheetName: string) => navigate(`/projects/timesheets/${encodeURIComponent(timesheetName)}?edit=1`);
|
||||||
|
const handleDuplicate = (timesheetName: string) => navigate(`/projects/timesheets/new?duplicate=${encodeURIComponent(timesheetName)}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/projects')} className="text-sm text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400">{t('projects.moduleTitle')}</button>
|
||||||
|
<span className="text-gray-400">/</span>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<FaClock className="text-indigo-500" /> {t('projects.timesheetDoctype')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all disabled:opacity-50"
|
||||||
|
disabled={totalCount === 0 && selectedRows.size === 0}
|
||||||
|
>
|
||||||
|
<FaFileExport /> <span className="font-medium">{t('listPages.export')}</span>
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (projectFromUrl) p.set('project', projectFromUrl);
|
||||||
|
const qs = p.toString();
|
||||||
|
navigate(qs ? `/projects/timesheets/new?${qs}` : '/projects/timesheets/new');
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
<FaPlus /> {t('projects.newTimesheet')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicExportModal
|
||||||
|
isOpen={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
doctype="Timesheet"
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
pageCount={timesheets.length}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageData={timesheets}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
rowKey="name"
|
||||||
|
onFetchAll={fetchAllForExport}
|
||||||
|
fileNamePrefix="timesheets"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Filter Panel ── */}
|
||||||
|
<div className="isolate bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-2.5 rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<button onClick={() => setIsFilterExpanded(v => !v)} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all">
|
||||||
|
{isFilterExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaFilter className="text-white" size={13} />
|
||||||
|
<span className="text-white font-semibold text-sm">Filters</span>
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||||
|
{[statusFilter, searchQuery, projectFromUrl, dateFilterBy && dateStart].filter(Boolean).length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex-1 overflow-x-auto mx-2">
|
||||||
|
<div className="flex items-center gap-2 py-0.5">
|
||||||
|
{searchQuery && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-gray-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">ID:</span> {searchQuery}
|
||||||
|
<button onClick={() => setSearchQuery('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{statusFilter && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Status:</span> {statusFilter}
|
||||||
|
<button onClick={() => setStatusFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{projectFromUrl && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">Project:</span> {projectFromUrl}
|
||||||
|
<button type="button" onClick={() => { setProjectDraft(''); setSearchParams(prev => { const n = new URLSearchParams(prev); n.delete('project'); n.set('page', '1'); return n; }); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dateFilterBy && dateStart && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||||||
|
<span className="font-semibold">{dateFilterBy === 'creation' ? 'Created' : 'Modified'}:</span> {dateStart}{dateEnd ? ` – ${dateEnd}` : ''}
|
||||||
|
<button type="button" onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={clearFilters} className="text-white/80 hover:text-white text-xs underline whitespace-nowrap">Clear all</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => refetch()} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all" title="Refresh">
|
||||||
|
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded body */}
|
||||||
|
{isFilterExpanded && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Project</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={projectDraft}
|
||||||
|
onChange={e => setProjectDraft(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && e.preventDefault()}
|
||||||
|
placeholder="Filter by project…"
|
||||||
|
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Timesheet ID</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||||||
|
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search by ID…"
|
||||||
|
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Submitted">Submitted</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Date Filter By</label>
|
||||||
|
<select value={dateFilterBy} onChange={e => setDateFilterBy(e.target.value as '' | 'creation' | 'modified')} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="creation">Created</option>
|
||||||
|
<option value="modified">Modified</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Sort By</label>
|
||||||
|
<select value={sortBy} onChange={e => setSortBy(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none">
|
||||||
|
<option value="creation desc">Created (newest)</option>
|
||||||
|
<option value="creation asc">Created (oldest)</option>
|
||||||
|
<option value="modified desc">Modified (newest)</option>
|
||||||
|
<option value="total_hours desc">Hours (highest)</option>
|
||||||
|
<option value="total_hours asc">Hours (lowest)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{dateFilterBy && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">From</label>
|
||||||
|
<input type="date" value={dateStart} onChange={e => setDateStart(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">To</label>
|
||||||
|
<input type="date" value={dateEnd} onChange={e => setDateEnd(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
|
||||||
|
) : timesheets.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<FaClock className="text-4xl text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">{t('projects.noTimesheets')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 px-4 py-3 text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllOnPage}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
|
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
aria-label="Select all on page"
|
||||||
|
>
|
||||||
|
{allOnPageSelected
|
||||||
|
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||||
|
: someOnPageSelected
|
||||||
|
? (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<FaSquare size={18} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-2 h-0.5 bg-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{[t('projects.timesheetId'), t('commonFields.status'), t('projects.totalHours'), 'Billable Hrs', 'Billing Amt', 'Costing Amt', 'Created', ''].map(h => (
|
||||||
|
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{timesheets.map((ts: Timesheet) => (
|
||||||
|
<tr
|
||||||
|
key={ts.name}
|
||||||
|
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(ts.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`}
|
||||||
|
onClick={() => navigate(`/projects/timesheets/${ts.name}`)}
|
||||||
|
>
|
||||||
|
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleRow(ts.name)}
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
|
aria-label={`Select ${ts.name}`}
|
||||||
|
>
|
||||||
|
{selectedRows.has(ts.name)
|
||||||
|
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
|
||||||
|
: <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{ts.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{ts.modified ? new Date(ts.modified).toLocaleDateString() : ''}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(ts.status || '')}`}>{ts.status || 'Draft'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">{ts.total_hours ?? 0} hrs</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{ts.total_billable_hours ?? 0} hrs</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{ts.total_billable_amount ?? '-'}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{ts.total_costing_amount ?? '-'}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{ts.creation ? new Date(ts.creation).toLocaleDateString() : '-'}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => navigate(`/projects/timesheets/${ts.name}`)} className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 p-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors" title="View">
|
||||||
|
<FaEye />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleEdit(ts.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit">
|
||||||
|
<FaEdit />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDuplicate(ts.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate">
|
||||||
|
<FaCopy />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
|
<ListPagination currentPage={currentPage} totalPages={totalPages} totalCount={totalCount} pageSize={pageSize} onPageChange={setCurrentPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimesheetList;
|
||||||
711
pm_app/src/pages/UserProfilePage.tsx
Normal file
711
pm_app/src/pages/UserProfilePage.tsx
Normal file
@ -0,0 +1,711 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FaArrowLeft,
|
||||||
|
FaSave,
|
||||||
|
FaUser,
|
||||||
|
FaEnvelope,
|
||||||
|
FaKey,
|
||||||
|
FaEye,
|
||||||
|
FaEyeSlash,
|
||||||
|
FaSpinner,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaTimesCircle,
|
||||||
|
FaExclamationTriangle,
|
||||||
|
FaUserTag,
|
||||||
|
FaIdBadge,
|
||||||
|
FaShieldAlt
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer, Bounce } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import {
|
||||||
|
fetchCurrentUserProfile,
|
||||||
|
updateUserProfile,
|
||||||
|
changeUserPassword,
|
||||||
|
type UserProfile,
|
||||||
|
type UpdateUserProfileData
|
||||||
|
} from '../services/userProfileService';
|
||||||
|
import {
|
||||||
|
fetchTwoFactorStatus,
|
||||||
|
resetOtpSecret,
|
||||||
|
type TwoFactorStatus,
|
||||||
|
} from '../services/twoFactorService';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const UserProfilePage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [isSystemManager, setIsSystemManager] = useState(false);
|
||||||
|
const [changingPassword, setChangingPassword] = useState(false);
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
first_name: '',
|
||||||
|
middle_name: '',
|
||||||
|
last_name: '',
|
||||||
|
role_profile_name: '',
|
||||||
|
custom_user_id : '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password change state
|
||||||
|
const [showPasswordSection, setShowPasswordSection] = useState(false);
|
||||||
|
const [passwordData, setPasswordData] = useState({
|
||||||
|
old_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
});
|
||||||
|
const [showOldPassword, setShowOldPassword] = useState(false);
|
||||||
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [twoFactorStatus, setTwoFactorStatus] = useState<TwoFactorStatus | null>(null);
|
||||||
|
const [twoFactorLoading, setTwoFactorLoading] = useState(true);
|
||||||
|
const [resettingOtp, setResettingOtp] = useState(false);
|
||||||
|
|
||||||
|
// Fetch user profile and check role on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProfile = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// NEW: Check if user is System Manager
|
||||||
|
const roleResponse = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
roles: 'System Manager'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const roleData = await roleResponse.json();
|
||||||
|
const isSysManager = roleData.message?.has_role || false;
|
||||||
|
setIsSystemManager(isSysManager);
|
||||||
|
console.log('Is System Manager:', isSysManager);
|
||||||
|
|
||||||
|
// Fetch profile data
|
||||||
|
const data = await fetchCurrentUserProfile();
|
||||||
|
|
||||||
|
// Debug: Log everything
|
||||||
|
console.log('Full profile data:', JSON.stringify(data, null, 2));
|
||||||
|
console.log('role_profile_name:', data.role_profile_name);
|
||||||
|
console.log('All keys:', Object.keys(data));
|
||||||
|
|
||||||
|
setProfile(data);
|
||||||
|
setFormData({
|
||||||
|
first_name: data.first_name || '',
|
||||||
|
middle_name: data.middle_name || '',
|
||||||
|
last_name: data.last_name || '',
|
||||||
|
role_profile_name: data.role_profile_name || '',
|
||||||
|
custom_user_id: data.custom_user_id || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tfa = await fetchTwoFactorStatus(data.name);
|
||||||
|
setTwoFactorStatus(tfa);
|
||||||
|
} catch (tfaErr) {
|
||||||
|
console.warn('Could not load 2FA status:', tfaErr);
|
||||||
|
setTwoFactorStatus(null);
|
||||||
|
} finally {
|
||||||
|
setTwoFactorLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to load profile';
|
||||||
|
setError(errorMsg);
|
||||||
|
toast.error(errorMsg, {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle form input changes
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle password input changes
|
||||||
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setPasswordData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save profile changes
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
if (!profile?.email) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
const updateData: UpdateUserProfileData = {
|
||||||
|
first_name: formData.first_name,
|
||||||
|
middle_name: formData.middle_name,
|
||||||
|
last_name: formData.last_name,
|
||||||
|
role_profile_name: formData.role_profile_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedProfile = await updateUserProfile(profile.email, updateData);
|
||||||
|
setProfile(prev => prev ? { ...prev, ...updatedProfile } : updatedProfile);
|
||||||
|
|
||||||
|
toast.success('Profile updated successfully!', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaCheckCircle />
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to update profile';
|
||||||
|
toast.error(errorMsg, {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetOtp = async () => {
|
||||||
|
if (!profile?.name) return;
|
||||||
|
if (!window.confirm(t('profile.resetOtpConfirm'))) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setResettingOtp(true);
|
||||||
|
await resetOtpSecret(profile.name);
|
||||||
|
toast.success(t('profile.resetOtpSuccess'), {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaCheckCircle />,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : t('profile.resetOtpFailed');
|
||||||
|
toast.error(errorMsg, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setResettingOtp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
// Validation
|
||||||
|
if (!passwordData.old_password) {
|
||||||
|
toast.error('Please enter your current password', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaExclamationTriangle />
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordData.new_password) {
|
||||||
|
toast.error('Please enter a new password', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaExclamationTriangle />
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordData.new_password.length < 8) {
|
||||||
|
toast.error('Password must be at least 8 characters long', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaExclamationTriangle />
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordData.new_password !== passwordData.confirm_password) {
|
||||||
|
toast.error('New passwords do not match', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaExclamationTriangle />
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setChangingPassword(true);
|
||||||
|
|
||||||
|
await changeUserPassword(passwordData.new_password, passwordData.old_password);
|
||||||
|
|
||||||
|
toast.success('Password changed successfully!', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaCheckCircle />
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear password fields
|
||||||
|
setPasswordData({
|
||||||
|
old_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
});
|
||||||
|
setShowPasswordSection(false);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to change password';
|
||||||
|
toast.error(errorMsg, {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setChangingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<FaSpinner className="animate-spin text-blue-500 text-4xl mx-auto" />
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading profile...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error && !profile) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">
|
||||||
|
<FaTimesCircle className="inline mr-2" />
|
||||||
|
Error Loading Profile
|
||||||
|
</h2>
|
||||||
|
<p className="text-red-700 dark:text-red-400">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="mt-4 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTwoFactor =
|
||||||
|
!twoFactorLoading &&
|
||||||
|
!!twoFactorStatus?.enabled_globally &&
|
||||||
|
!!twoFactorStatus?.required_for_user &&
|
||||||
|
!!twoFactorStatus?.otp_app;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||||
|
<ToastContainer
|
||||||
|
position="top-right"
|
||||||
|
autoClose={4000}
|
||||||
|
hideProgressBar={false}
|
||||||
|
newestOnTop
|
||||||
|
closeOnClick
|
||||||
|
rtl={false}
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
pauseOnHover
|
||||||
|
theme="colored"
|
||||||
|
transition={Bounce}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FaArrowLeft />
|
||||||
|
<span className="text-gray-900 dark:text-white text-xl font-semibold">User Profile</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSaveProfile}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50 shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaSave />
|
||||||
|
Save Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{/* Profile Header */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-6 text-center">
|
||||||
|
<div className="w-24 h-24 bg-white/20 rounded-full mx-auto flex items-center justify-center mb-4">
|
||||||
|
{profile?.user_image ? (
|
||||||
|
<img
|
||||||
|
src={profile.user_image}
|
||||||
|
alt="Profile"
|
||||||
|
className="w-20 h-20 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaUser className="text-white text-4xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white">{profile?.full_name || 'User'}</h2>
|
||||||
|
<p className="text-white/80 text-sm mt-1">{profile?.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Stats */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<FaIdBadge className="text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Username</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{profile?.username || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<FaUserTag className="text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Role Profile</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{profile?.role_profile_name || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<FaShieldAlt className="text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Status</p>
|
||||||
|
<p className={`text-sm font-medium ${profile?.enabled ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||||
|
{profile?.enabled ? 'Active' : 'Inactive'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{twoFactorLoading && (
|
||||||
|
<div className="flex items-center gap-2 px-1 py-1 text-[11px] text-gray-400">
|
||||||
|
<FaSpinner className="animate-spin shrink-0" size={12} />
|
||||||
|
<span>{t('profile.twoFactorLoading')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTwoFactor && (
|
||||||
|
<div className="rounded-lg border border-purple-200 bg-purple-50/80 p-3 dark:border-purple-800 dark:bg-purple-900/20">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FaShieldAlt className="mt-0.5 shrink-0 text-purple-600 dark:text-purple-400" size={13} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold text-purple-900 dark:text-purple-200">
|
||||||
|
{t('profile.twoFactorSidebarTitle')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] leading-snug text-purple-800/90 dark:text-purple-300/90">
|
||||||
|
{t('profile.twoFactorRequired')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[10px] leading-snug text-gray-600 dark:text-gray-400">
|
||||||
|
{t('profile.twoFactorOtpAppNoteShort')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetOtp}
|
||||||
|
disabled={resettingOtp}
|
||||||
|
className="mt-2 inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-purple-300 bg-white px-2 py-1.5 text-[11px] font-medium text-purple-800 hover:bg-purple-50 disabled:opacity-50 dark:border-purple-600 dark:bg-purple-950/40 dark:text-purple-200 dark:hover:bg-purple-900/40"
|
||||||
|
>
|
||||||
|
{resettingOtp ? (
|
||||||
|
<FaSpinner className="animate-spin" size={10} />
|
||||||
|
) : (
|
||||||
|
<FaShieldAlt size={10} />
|
||||||
|
)}
|
||||||
|
{t('profile.resetOtp')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaUser className="text-blue-500" />
|
||||||
|
Basic Information
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Email (Read-only) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Email
|
||||||
|
<span className="ml-1 text-gray-400">(Read-only)</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FaEnvelope className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={14} />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={profile?.email || ''}
|
||||||
|
disabled
|
||||||
|
className="w-full pl-10 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full Name (Read-only) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Full Name
|
||||||
|
<span className="ml-1 text-gray-400">(Auto-generated)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profile?.full_name || ''}
|
||||||
|
disabled
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* First Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
First Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="first_name"
|
||||||
|
value={formData.first_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Enter first name"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Middle Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="middle_name"
|
||||||
|
value={formData.middle_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Enter middle name"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="last_name"
|
||||||
|
value={formData.last_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Enter last name"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
User ID No
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="custom_user_id"
|
||||||
|
value={formData.custom_user_id}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Enter User No"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Profile */}
|
||||||
|
{/* <div>
|
||||||
|
<LinkField
|
||||||
|
label="Role Profile"
|
||||||
|
doctype="Role Profile"
|
||||||
|
value={formData.role_profile_name}
|
||||||
|
onChange={(val) => setFormData(prev => ({ ...prev, role_profile_name: val }))}
|
||||||
|
placeholder="Select Role Profile"
|
||||||
|
disabled={false}
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* Role Profile - Only System Manager can edit */}
|
||||||
|
<div>
|
||||||
|
<LinkField
|
||||||
|
label="Role Profile"
|
||||||
|
doctype="Role Profile"
|
||||||
|
value={formData.role_profile_name}
|
||||||
|
onChange={(val) => setFormData(prev => ({ ...prev, role_profile_name: val }))}
|
||||||
|
placeholder="Select Role Profile"
|
||||||
|
disabled={!isSystemManager}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||||
|
<FaKey className="text-orange-500" />
|
||||||
|
Change Password
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPasswordSection(!showPasswordSection)}
|
||||||
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{showPasswordSection ? 'Cancel' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPasswordSection ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Current Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Current Password <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showOldPassword ? 'text' : 'password'}
|
||||||
|
name="old_password"
|
||||||
|
value={passwordData.old_password}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
placeholder="Enter current password"
|
||||||
|
className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOldPassword(!showOldPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showOldPassword ? <FaEyeSlash size={14} /> : <FaEye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
New Password <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
name="new_password"
|
||||||
|
value={passwordData.new_password}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
placeholder="Enter new password (min 8 characters)"
|
||||||
|
className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showNewPassword ? <FaEyeSlash size={14} /> : <FaEye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{passwordData.new_password && passwordData.new_password.length < 8 && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">Password must be at least 8 characters</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm New Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Confirm New Password <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
name="confirm_password"
|
||||||
|
value={passwordData.confirm_password}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <FaEyeSlash size={14} /> : <FaEye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{passwordData.confirm_password && passwordData.new_password !== passwordData.confirm_password && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">Passwords do not match</p>
|
||||||
|
)}
|
||||||
|
{passwordData.confirm_password && passwordData.new_password === passwordData.confirm_password && passwordData.new_password.length >= 8 && (
|
||||||
|
<p className="mt-1 text-xs text-green-500 flex items-center gap-1">
|
||||||
|
<FaCheckCircle size={10} /> Passwords match
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Change Password Button */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleChangePassword}
|
||||||
|
disabled={changingPassword}
|
||||||
|
className="w-full bg-orange-600 hover:bg-orange-700 text-white px-4 py-2.5 rounded-lg flex items-center justify-center gap-2 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{changingPassword ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin" />
|
||||||
|
Changing Password...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaKey />
|
||||||
|
Change Password
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||||
|
<FaKey className="mx-auto text-3xl mb-2 text-gray-300 dark:text-gray-600" />
|
||||||
|
<p className="text-sm">Click "Change Password" to update your password</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserProfilePage;
|
||||||
737
pm_app/src/services/apiService.ts
Normal file
737
pm_app/src/services/apiService.ts
Normal file
@ -0,0 +1,737 @@
|
|||||||
|
import API_CONFIG from '../config/api';
|
||||||
|
|
||||||
|
// Define interfaces locally to avoid import issues
|
||||||
|
interface ApiResponse<T = any> {
|
||||||
|
message?: T;
|
||||||
|
error?: string;
|
||||||
|
status_code?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserDetails {
|
||||||
|
user_id: string;
|
||||||
|
full_name: string;
|
||||||
|
email: string;
|
||||||
|
user_image?: string;
|
||||||
|
roles: string[];
|
||||||
|
permissions: Record<string, {
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
|
create: boolean;
|
||||||
|
delete: boolean;
|
||||||
|
}>;
|
||||||
|
last_login?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
creation: string;
|
||||||
|
modified: string;
|
||||||
|
language: string;
|
||||||
|
custom_site_name?: string;
|
||||||
|
custom_phcc_site_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocTypeRecord {
|
||||||
|
name: string;
|
||||||
|
creation: string;
|
||||||
|
modified: string;
|
||||||
|
modified_by: string;
|
||||||
|
owner: string;
|
||||||
|
docstatus: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocTypeRecordsResponse {
|
||||||
|
records: DocTypeRecord[];
|
||||||
|
total_count: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
has_more: boolean;
|
||||||
|
doctype: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
total_users: number;
|
||||||
|
total_customers: number;
|
||||||
|
total_items: number;
|
||||||
|
total_orders: number;
|
||||||
|
recent_activities: RecentActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard number cards
|
||||||
|
interface NumberCards {
|
||||||
|
total_assets: number;
|
||||||
|
work_orders_open: number;
|
||||||
|
work_orders_in_progress: number;
|
||||||
|
work_orders_completed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard chart payload
|
||||||
|
interface ChartDataset { name: string; values: number[]; color?: string }
|
||||||
|
interface ChartResponse {
|
||||||
|
labels: string[];
|
||||||
|
datasets: ChartDataset[];
|
||||||
|
type: 'Bar' | 'Pie' | 'Line' | string;
|
||||||
|
options?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentActivity {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
creation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KycRecord {
|
||||||
|
name: string;
|
||||||
|
kyc_status: string;
|
||||||
|
kyc_type: string;
|
||||||
|
creation: string;
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KycDetailsResponse {
|
||||||
|
records: KycRecord[];
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
approved: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginUserMessage {
|
||||||
|
full_name?: string;
|
||||||
|
user_id?: string;
|
||||||
|
email?: string;
|
||||||
|
home_page?: string;
|
||||||
|
sid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorVerification {
|
||||||
|
method: string;
|
||||||
|
setup?: boolean;
|
||||||
|
prompt?: string;
|
||||||
|
token_delivery?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginResult =
|
||||||
|
| { status: 'logged_in'; user: LoginUserMessage }
|
||||||
|
| {
|
||||||
|
status: 'two_factor_required';
|
||||||
|
tmp_id: string;
|
||||||
|
verification: TwoFactorVerification;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LoginCredentials {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileUploadOptions {
|
||||||
|
file: File;
|
||||||
|
doctype: string;
|
||||||
|
docname: string;
|
||||||
|
fieldname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// USER PERMISSION INTERFACES
|
||||||
|
interface RestrictionInfo {
|
||||||
|
field: string;
|
||||||
|
values: string[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionFiltersResponse {
|
||||||
|
is_admin: boolean;
|
||||||
|
filters: Record<string, any>;
|
||||||
|
restrictions: Record<string, RestrictionInfo>;
|
||||||
|
target_doctype: string;
|
||||||
|
user?: string;
|
||||||
|
total_restrictions?: number;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AllowedValuesResponse {
|
||||||
|
is_admin: boolean;
|
||||||
|
allowed_values: string[];
|
||||||
|
default_value?: string | null;
|
||||||
|
has_restriction: boolean;
|
||||||
|
allow_doctype: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentAccessResponse {
|
||||||
|
has_access: boolean;
|
||||||
|
is_admin?: boolean;
|
||||||
|
no_restrictions?: boolean;
|
||||||
|
error?: string;
|
||||||
|
denied_by?: string;
|
||||||
|
field?: string;
|
||||||
|
document_value?: string;
|
||||||
|
allowed_values?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserDefaultsResponse {
|
||||||
|
is_admin: boolean;
|
||||||
|
defaults: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiService {
|
||||||
|
private baseURL: string;
|
||||||
|
// private token: string | null = null;
|
||||||
|
private endpoints: Record<string, string>;
|
||||||
|
private defaultHeaders: Record<string, string>;
|
||||||
|
private timeout: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.baseURL = API_CONFIG.BASE_URL;
|
||||||
|
this.endpoints = API_CONFIG.ENDPOINTS;
|
||||||
|
this.defaultHeaders = API_CONFIG.DEFAULT_HEADERS;
|
||||||
|
this.timeout = API_CONFIG.TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CSRF Token for authenticated requests
|
||||||
|
async getCSRFToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// First, try to get CSRF token from window (injected by Frappe in HTML)
|
||||||
|
if (typeof window !== 'undefined' && (window as any).csrf_token) {
|
||||||
|
return (window as any).csrf_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in window, try to fetch from API (but only if user is authenticated)
|
||||||
|
// Check if user is logged in by checking localStorage
|
||||||
|
const user = localStorage.getItem('user');
|
||||||
|
if (!user) {
|
||||||
|
// User not logged in, skip CSRF token fetch
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}${this.endpoints.CSRF_TOKEN}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include' // Include cookies for session
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: ApiResponse<string> = await response.json();
|
||||||
|
return data.message || null;
|
||||||
|
} else {
|
||||||
|
// Silently fail - CSRF token not required for GET requests
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail - CSRF token not required for GET requests
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic API call method
|
||||||
|
async apiCall<T = any>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
|
|
||||||
|
const defaultOptions: RequestInit = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...options.headers,
|
||||||
|
// 'Authorization': `token ${this.token}`
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add CSRF token for non-GET requests
|
||||||
|
// if (defaultOptions.method !== 'GET') {
|
||||||
|
const csrfToken = await this.getCSRFToken();
|
||||||
|
if (csrfToken) {
|
||||||
|
(defaultOptions.headers as Record<string, string>)['X-Frappe-CSRF-Token'] = csrfToken;
|
||||||
|
}
|
||||||
|
// }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...defaultOptions,
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData: ApiResponse = await response.json().catch(() => ({}));
|
||||||
|
throw new ApiError(
|
||||||
|
errorData.error || `HTTP error! status: ${response.status}`,
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ApiResponse<T> = await response.json();
|
||||||
|
|
||||||
|
// Handle Frappe API response format
|
||||||
|
if (data.message !== undefined) {
|
||||||
|
return data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('API call failed:', error);
|
||||||
|
throw new ApiError(error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFrappeLoginError(data: Record<string, unknown>, status: number): string {
|
||||||
|
if (typeof data.message === 'string' && data.message) return data.message;
|
||||||
|
if (typeof data.exc === 'string' && data.exc) {
|
||||||
|
const match = data.exc.match(/:\s*(.+)$/);
|
||||||
|
return match ? match[1].trim() : data.exc;
|
||||||
|
}
|
||||||
|
if (data._server_messages) {
|
||||||
|
try {
|
||||||
|
const msgs = JSON.parse(String(data._server_messages)) as string[];
|
||||||
|
const parsed = msgs.map((m) => JSON.parse(m) as { message?: string });
|
||||||
|
const text = parsed.map((p) => p.message).filter(Boolean).join(' ');
|
||||||
|
if (text) return text;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status === 401) return 'Invalid credentials or verification code.';
|
||||||
|
return 'Login failed. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLoggedInUser(data: Record<string, unknown>, fallbackEmail?: string): LoginUserMessage | null {
|
||||||
|
if (typeof data.message === 'string' && data.message === 'Logged In') {
|
||||||
|
return {
|
||||||
|
full_name: data.full_name as string | undefined,
|
||||||
|
user_id: (data.user as string) || fallbackEmail,
|
||||||
|
home_page: data.home_page as string | undefined,
|
||||||
|
sid: data.sid as string | undefined,
|
||||||
|
email: fallbackEmail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data.message && typeof data.message === 'object') {
|
||||||
|
return data.message as LoginUserMessage;
|
||||||
|
}
|
||||||
|
if (data.full_name || data.user) {
|
||||||
|
return {
|
||||||
|
full_name: data.full_name as string | undefined,
|
||||||
|
user_id: (data.user as string) || fallbackEmail,
|
||||||
|
home_page: data.home_page as string | undefined,
|
||||||
|
sid: data.sid as string | undefined,
|
||||||
|
email: fallbackEmail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postLoginRequest(formData: FormData): Promise<Record<string, unknown>> {
|
||||||
|
const url = `${this.baseURL}${this.endpoints.LOGIN}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = await response.text();
|
||||||
|
let data: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
data = raw ? JSON.parse(raw) : {};
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = import.meta.env.DEV
|
||||||
|
? this.parseFrappeLoginError(data, response.status)
|
||||||
|
: response.status === 401
|
||||||
|
? 'Invalid credentials or verification code.'
|
||||||
|
: 'Login failed. Please try again.';
|
||||||
|
throw new ApiError(errorMessage, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapLoginResponse(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
fallbackEmail?: string
|
||||||
|
): LoginResult {
|
||||||
|
const verification = data.verification as TwoFactorVerification | undefined;
|
||||||
|
const tmpId = data.tmp_id as string | undefined;
|
||||||
|
const message = data.message;
|
||||||
|
|
||||||
|
if (
|
||||||
|
verification &&
|
||||||
|
tmpId &&
|
||||||
|
message !== 'Logged In'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 'two_factor_required',
|
||||||
|
tmp_id: tmpId,
|
||||||
|
verification,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this.parseLoggedInUser(data, fallbackEmail);
|
||||||
|
if (user && (message === 'Logged In' || user.sid || user.user_id)) {
|
||||||
|
return { status: 'logged_in', user };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError('Unexpected login response.', 500, 'INVALID_RESPONSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication Methods
|
||||||
|
async login(credentials: LoginCredentials): Promise<LoginResult> {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('[API Service] Login attempt for:', credentials.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('usr', credentials.email);
|
||||||
|
formData.append('pwd', credentials.password);
|
||||||
|
|
||||||
|
const data = await this.postLoginRequest(formData);
|
||||||
|
const result = this.mapLoginResponse(data, credentials.email);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && result.status === 'logged_in') {
|
||||||
|
console.log('[API Service] Login successful');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Second step after password when Frappe requires OTP (OTP App). */
|
||||||
|
async verifyLoginOtp(tmpId: string, otp: string): Promise<LoginResult> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('otp', otp.trim());
|
||||||
|
formData.append('tmp_id', tmpId);
|
||||||
|
|
||||||
|
const data = await this.postLoginRequest(formData);
|
||||||
|
const result = this.mapLoginResponse(data);
|
||||||
|
|
||||||
|
if (result.status !== 'logged_in') {
|
||||||
|
throw new ApiError('Verification failed. Please try again.', 401, 'OTP_FAILED');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guest POST to Frappe — sends the same password-reset email as Desk "Forgot password".
|
||||||
|
* The email link targets the Frappe site URL (update password on the server).
|
||||||
|
*/
|
||||||
|
async requestPasswordReset(userEmail: string, signal?: AbortSignal): Promise<void> {
|
||||||
|
const trimmed = userEmail.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new ApiError('Email is required', 400, 'EMPTY_EMAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfToken = await this.getCSRFTokenForGuest();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
};
|
||||||
|
if (csrfToken) {
|
||||||
|
headers['X-Frappe-CSRF-Token'] = csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.baseURL}${this.endpoints.RESET_PASSWORD}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 45000);
|
||||||
|
const combinedSignal = signal
|
||||||
|
? AbortSignal.any([signal, controller.signal])
|
||||||
|
: controller.signal;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: new URLSearchParams({ user: trimmed }).toString(),
|
||||||
|
credentials: 'include',
|
||||||
|
signal: combinedSignal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = await response.text();
|
||||||
|
let data: { message?: unknown } = {};
|
||||||
|
try {
|
||||||
|
data = raw ? JSON.parse(raw) : {};
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg =
|
||||||
|
typeof data.message === 'string'
|
||||||
|
? data.message
|
||||||
|
: typeof data.message === 'object' &&
|
||||||
|
data.message !== null &&
|
||||||
|
'message' in (data.message as object)
|
||||||
|
? String((data.message as { message?: string }).message)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (response.status === 404 || msg.toLowerCase().includes('not found')) {
|
||||||
|
throw new ApiError('User not found', 404, 'USER_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new ApiError('Could not send reset email', 403, 'FORBIDDEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
msg || `HTTP error! status: ${response.status}`,
|
||||||
|
response.status,
|
||||||
|
'REQUEST_FAILED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg === 'disabled' || msg === 'not allowed') {
|
||||||
|
throw new ApiError('Reset not allowed', 200, 'RESET_NOT_ALLOWED');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSRF for guest flows (login page injects window.csrf_token). */
|
||||||
|
async getCSRFTokenForGuest(): Promise<string | null> {
|
||||||
|
const win = typeof window !== 'undefined' ? (window as { csrf_token?: string }) : undefined;
|
||||||
|
if (win?.csrf_token) {
|
||||||
|
return win.csrf_token;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseURL}${this.endpoints.CSRF_TOKEN}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data: ApiResponse<string> = await response.json();
|
||||||
|
return data.message || null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// async login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||||
|
// const formData = new FormData();
|
||||||
|
// formData.append('usr', credentials.email);
|
||||||
|
// formData.append('pwd', credentials.password);
|
||||||
|
|
||||||
|
// const response = await fetch(`${this.baseURL}/api/method/login`, {
|
||||||
|
// method: 'POST',
|
||||||
|
// body: formData,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const data = await response.json();
|
||||||
|
|
||||||
|
// if (data.message === 'Logged In') {
|
||||||
|
// // Now get API keys or generate token
|
||||||
|
// await this.fetchApiKeys(credentials);
|
||||||
|
// return { message: data } as LoginResponse;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// throw new ApiError('Login failed');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private async fetchApiKeys(credentials: LoginCredentials): Promise<void> {
|
||||||
|
// // Use Basic Auth to get API keys
|
||||||
|
// const basicAuth = btoa(`${credentials.email}:${credentials.password}`);
|
||||||
|
|
||||||
|
// // First, check if user has API keys, if not generate them
|
||||||
|
// const response = await fetch(
|
||||||
|
// `${this.baseURL}/api/method/frappe.core.doctype.user.user.generate_keys?user=${encodeURIComponent(credentials.email)}`,
|
||||||
|
// {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Basic ${basicAuth}`,
|
||||||
|
// 'Accept': 'application/json',
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const data = await response.json();
|
||||||
|
|
||||||
|
// if (data.message?.api_secret) {
|
||||||
|
// // Fetch the api_key from user doc
|
||||||
|
// const userResponse = await fetch(
|
||||||
|
// `${this.baseURL}/api/resource/User/${encodeURIComponent(credentials.email)}`,
|
||||||
|
// {
|
||||||
|
// method: 'GET',
|
||||||
|
// headers: {
|
||||||
|
// 'Authorization': `Basic ${basicAuth}`,
|
||||||
|
// 'Accept': 'application/json',
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const userData = await userResponse.json();
|
||||||
|
// const apiKey = userData.data.api_key;
|
||||||
|
// const apiSecret = data.message.api_secret;
|
||||||
|
|
||||||
|
// // Store the token
|
||||||
|
// this.token = `${apiKey}:${apiSecret}`;
|
||||||
|
// localStorage.setItem('auth_token', this.token);
|
||||||
|
// localStorage.setItem('user_email', credentials.email);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
await this.apiCall(this.endpoints.LOGOUT, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Management
|
||||||
|
async getUserDetails(userId?: string): Promise<UserDetails> {
|
||||||
|
const params = userId ? `?user_id=${userId}` : '';
|
||||||
|
return this.apiCall<UserDetails>(`${this.endpoints.USER_DETAILS}${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Management
|
||||||
|
async getDoctypeRecords(
|
||||||
|
doctype: string,
|
||||||
|
filters?: Record<string, any>,
|
||||||
|
fields?: string[],
|
||||||
|
limit: number = 20,
|
||||||
|
offset: number = 0
|
||||||
|
): Promise<DocTypeRecordsResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
doctype,
|
||||||
|
limit: limit.toString(),
|
||||||
|
offset: offset.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
params.append('filters', JSON.stringify(filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields) {
|
||||||
|
params.append('fields', JSON.stringify(fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.apiCall<DocTypeRecordsResponse>(`${this.endpoints.DOCTYPE_RECORDS}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
async getDashboardStats(): Promise<DashboardStats> {
|
||||||
|
return this.apiCall<DashboardStats>(this.endpoints.DASHBOARD_STATS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNumberCards(): Promise<NumberCards> {
|
||||||
|
return this.apiCall<NumberCards>(this.endpoints.DASHBOARD_NUMBER_CARDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDashboardCharts(publicOnly: boolean = true): Promise<{ charts: any[] }> {
|
||||||
|
const params = new URLSearchParams({ public_only: publicOnly ? '1' : '0' });
|
||||||
|
return this.apiCall<{ charts: any[] }>(`${this.endpoints.DASHBOARD_LIST_CHARTS}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDashboardChartData(chartName: string, filters?: Record<string, any>): Promise<ChartResponse> {
|
||||||
|
const params = new URLSearchParams({ chart_name: chartName });
|
||||||
|
if (filters) params.append('report_filters', JSON.stringify(filters));
|
||||||
|
return this.apiCall<ChartResponse>(`${this.endpoints.DASHBOARD_CHART_DATA}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KYC Management
|
||||||
|
async getKycDetails(): Promise<KycDetailsResponse> {
|
||||||
|
return this.apiCall<KycDetailsResponse>(this.endpoints.KYC_DETAILS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Upload
|
||||||
|
async uploadFile(options: FileUploadOptions): Promise<any> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', options.file);
|
||||||
|
formData.append('doctype', options.doctype);
|
||||||
|
formData.append('docname', options.docname);
|
||||||
|
formData.append('fieldname', options.fieldname);
|
||||||
|
|
||||||
|
return this.apiCall(this.endpoints.UPLOAD_FILE, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {}, // Don't set Content-Type for FormData
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// USER PERMISSION METHODS
|
||||||
|
async getUserPermissions(userId?: string): Promise<any> {
|
||||||
|
const params = userId ? `?user=${encodeURIComponent(userId)}` : '';
|
||||||
|
return this.apiCall(`${this.endpoints.GET_USER_PERMISSIONS}${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPermissionFilters(targetDoctype: string, userId?: string): Promise<PermissionFiltersResponse> {
|
||||||
|
const params = new URLSearchParams({ target_doctype: targetDoctype });
|
||||||
|
if (userId) params.append('user', userId);
|
||||||
|
return this.apiCall<PermissionFiltersResponse>(`${this.endpoints.GET_PERMISSION_FILTERS}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllowedValues(allowDoctype: string, userId?: string): Promise<AllowedValuesResponse> {
|
||||||
|
const params = new URLSearchParams({ allow_doctype: allowDoctype });
|
||||||
|
if (userId) params.append('user', userId);
|
||||||
|
return this.apiCall<AllowedValuesResponse>(`${this.endpoints.GET_ALLOWED_VALUES}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkDocumentAccess(doctype: string, docname: string, userId?: string): Promise<DocumentAccessResponse> {
|
||||||
|
const params = new URLSearchParams({ doctype, docname });
|
||||||
|
if (userId) params.append('user', userId);
|
||||||
|
return this.apiCall<DocumentAccessResponse>(`${this.endpoints.CHECK_DOCUMENT_ACCESS}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfiguredDoctypes(): Promise<any> {
|
||||||
|
return this.apiCall(this.endpoints.GET_CONFIGURED_DOCTYPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserDefaults(userId?: string): Promise<UserDefaultsResponse> {
|
||||||
|
const params = userId ? `?user=${encodeURIComponent(userId)}` : '';
|
||||||
|
return this.apiCall<UserDefaultsResponse>(`${this.endpoints.GET_USER_DEFAULTS}${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility Methods
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
// Check if user is authenticated (implement based on your auth strategy)
|
||||||
|
return !!localStorage.getItem('frappe_session_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionId(): string | null {
|
||||||
|
return localStorage.getItem('frappe_session_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionId(sessionId: string): void {
|
||||||
|
localStorage.setItem('frappe_session_id', sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom Error Class
|
||||||
|
class ApiError extends Error {
|
||||||
|
public status?: number;
|
||||||
|
public code?: string;
|
||||||
|
|
||||||
|
constructor(message: string, status?: number, code?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export singleton instance
|
||||||
|
const apiService = new ApiService();
|
||||||
|
export default apiService;
|
||||||
|
export { ApiError };
|
||||||
|
export type { LoginCredentials };
|
||||||
102
pm_app/src/services/deliveryNoteService.ts
Normal file
102
pm_app/src/services/deliveryNoteService.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||||
|
|
||||||
|
export interface DeliveryNoteItem {
|
||||||
|
name?: string; item_code?: string; item_name?: string; description?: string;
|
||||||
|
qty?: number; uom?: string; stock_uom?: string; conversion_factor?: number;
|
||||||
|
rate?: number; amount?: number; net_rate?: number; net_amount?: number;
|
||||||
|
price_list_rate?: number; discount_percentage?: number;
|
||||||
|
against_sales_order?: string; so_detail?: string;
|
||||||
|
project?: string; cost_center?: string;
|
||||||
|
is_free_item?: number; grant_commission?: number; idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeliveryNote {
|
||||||
|
name: string; docstatus?: number; owner?: string; creation?: string; modified?: string;
|
||||||
|
naming_series?: string; posting_date?: string; posting_time?: string; set_posting_time?: number;
|
||||||
|
customer?: string; customer_name?: string; company?: string;
|
||||||
|
project?: string; cost_center?: string; currency?: string;
|
||||||
|
selling_price_list?: string; status?: string; is_return?: number;
|
||||||
|
items?: DeliveryNoteItem[]; taxes?: any[];
|
||||||
|
grand_total?: number; net_total?: number; total?: number;
|
||||||
|
total_taxes_and_charges?: number; rounded_total?: number; [key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeliveryNoteService {
|
||||||
|
private async getCSRFToken(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||||
|
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||||
|
if (res.ok) { const json = await res.json(); if (json.message) { (window as any).csrf_token = json.message; return json.message; } }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders(): Promise<Record<string, string>> {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||||
|
const csrf = await this.getCSRFToken();
|
||||||
|
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
|
const r = await fetch(url, { credentials: 'include', ...opts });
|
||||||
|
const body = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
const msg = formatFrappeApiError(body) || body?.exc_type || `HTTP ${r.status}`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDeliveryNotes(params: { filters?: any[]; limit_start?: number; limit_page_length?: number; order_by?: string } = {}): Promise<DeliveryNote[]> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
const fields = ['name','customer','customer_name','posting_date','status','grand_total','currency','docstatus','creation'];
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||||
|
q.set('limit_start', String(params.limit_start ?? 0));
|
||||||
|
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||||
|
q.set('order_by', params.order_by ?? 'creation desc');
|
||||||
|
const body = await this.fetchJson(`/api/resource/Delivery Note?${q}`);
|
||||||
|
return body.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDeliveryNoteCount(filters: any[] = []): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||||
|
try {
|
||||||
|
const body = await this.fetchJson(`/api/resource/Delivery Note?${q}`);
|
||||||
|
return body.data?.[0]?.count ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDeliveryNote(name: string): Promise<DeliveryNote> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/Delivery Note/${encodeURIComponent(name)}`);
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDeliveryNote(data: Partial<DeliveryNote>): Promise<DeliveryNote> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const body = await this.fetchJson('/api/resource/Delivery Note', { method: 'POST', headers, body: JSON.stringify({ ...data, doctype: 'Delivery Note' }) });
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDeliveryNote(name: string, data: Partial<DeliveryNote>): Promise<DeliveryNote> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const body = await this.fetchJson(`/api/resource/Delivery Note/${encodeURIComponent(name)}`, { method: 'PUT', headers, body: JSON.stringify(data) });
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitDeliveryNote(name: string): Promise<DeliveryNote> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const body = await this.fetchJson(`/api/resource/Delivery Note/${encodeURIComponent(name)}`, { method: 'PUT', headers, body: JSON.stringify({ docstatus: 1 }) });
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new DeliveryNoteService();
|
||||||
189
pm_app/src/services/masterService.ts
Normal file
189
pm_app/src/services/masterService.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import API_CONFIG from '../config/api';
|
||||||
|
import { toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||||
|
|
||||||
|
// ─── Customer ─────────────────────────────────────────────────────────────────
|
||||||
|
export interface Customer {
|
||||||
|
name: string;
|
||||||
|
naming_series?: string;
|
||||||
|
customer_name?: string;
|
||||||
|
customer_type?: string; // Company | Individual | Hospital
|
||||||
|
customer_group?: string;
|
||||||
|
territory?: string;
|
||||||
|
is_internal_customer?: number;
|
||||||
|
language?: string;
|
||||||
|
default_commission_rate?: number;
|
||||||
|
so_required?: number;
|
||||||
|
dn_required?: number;
|
||||||
|
is_frozen?: number;
|
||||||
|
disabled?: number;
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
docstatus?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Employee ─────────────────────────────────────────────────────────────────
|
||||||
|
export interface Employee {
|
||||||
|
name: string;
|
||||||
|
naming_series?: string;
|
||||||
|
employee_name?: string;
|
||||||
|
first_name?: string;
|
||||||
|
middle_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
salutation?: string;
|
||||||
|
gender?: string;
|
||||||
|
date_of_birth?: string;
|
||||||
|
date_of_joining?: string;
|
||||||
|
status?: string; // Active | Inactive | Left | On Leave
|
||||||
|
// Company Details section
|
||||||
|
company?: string; // Labeled "Hospital" in this system
|
||||||
|
designation?: string;
|
||||||
|
branch?: string;
|
||||||
|
department?: string;
|
||||||
|
reports_to?: string;
|
||||||
|
employee_number?: string;
|
||||||
|
user_id?: string;
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
docstatus?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Service ──────────────────────────────────────────────────────────────────
|
||||||
|
class MasterService {
|
||||||
|
private readonly baseURL = API_CONFIG.BASE_URL;
|
||||||
|
|
||||||
|
private async getCSRFToken(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||||
|
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.baseURL}/api/method/frappe.sessions.get_csrf_token`, { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.message) { (window as any).csrf_token = json.message; return json.message; }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders(): Promise<Record<string, string>> {
|
||||||
|
const h: Record<string, string> = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||||
|
const csrf = await this.getCSRFToken();
|
||||||
|
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parseFrappeError(response: Response): Promise<Error> {
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body._server_messages) {
|
||||||
|
try {
|
||||||
|
const msgs = JSON.parse(body._server_messages);
|
||||||
|
const parsed = typeof msgs[0] === 'string' ? JSON.parse(msgs[0]) : msgs[0];
|
||||||
|
if (parsed?.message) return new Error(parsed.message);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (body.message && typeof body.message === 'string') return new Error(body.message);
|
||||||
|
if (body.exc_type) return new Error(`${body.exc_type}: ${body.exc?.split('\n').slice(-2).join(' ').trim() || response.statusText}`);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
const map: Record<number, string> = { 403: 'Permission denied.', 404: 'Record not found.', 417: 'Validation error.', 500: 'Server error.' };
|
||||||
|
return new Error(map[response.status] || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(url: string, options?: RequestInit): Promise<any> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: 'include', ...options,
|
||||||
|
headers: { ...await this.getHeaders(), ...(options?.headers || {}) },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw await this.parseFrappeError(response.clone());
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Customer ───────────────────────────────────────────────────────────────
|
||||||
|
async getCustomers(params: { limit_start?: number; limit_page_length?: number; filters?: Record<string, any> } = {}): Promise<{ data: Customer[] }> {
|
||||||
|
const { limit_start = 0, limit_page_length = 20, filters = {} } = params;
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['name','customer_name','customer_type','customer_group','territory','disabled','creation','modified']));
|
||||||
|
q.set('limit_start', String(limit_start));
|
||||||
|
q.set('limit_page_length', String(limit_page_length));
|
||||||
|
q.set('order_by', 'modified desc');
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
const fa = toFrappeFilterArray(filters);
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
}
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer?${q}`);
|
||||||
|
return { data: r.data || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCustomer(name: string): Promise<Customer> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer/${encodeURIComponent(name)}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCustomer(data: Partial<Customer>): Promise<Customer> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCustomer(name: string, data: Partial<Customer>): Promise<Customer> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCustomerCount(filters: Record<string, any> = {}): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
const fa = toFrappeFilterArray(filters);
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
}
|
||||||
|
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Customer?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Employee ───────────────────────────────────────────────────────────────
|
||||||
|
async getEmployees(params: { limit_start?: number; limit_page_length?: number; filters?: Record<string, any> } = {}): Promise<{ data: Employee[] }> {
|
||||||
|
const { limit_start = 0, limit_page_length = 20, filters = {} } = params;
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['name','employee_name','first_name','last_name','gender','status','company','designation','department','date_of_joining','creation','modified']));
|
||||||
|
q.set('limit_start', String(limit_start));
|
||||||
|
q.set('limit_page_length', String(limit_page_length));
|
||||||
|
q.set('order_by', 'modified desc');
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
const fa = toFrappeFilterArray(filters);
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
}
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee?${q}`);
|
||||||
|
return { data: r.data || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmployee(name: string): Promise<Employee> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee/${encodeURIComponent(name)}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEmployee(data: Partial<Employee>): Promise<Employee> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmployee(name: string, data: Partial<Employee>): Promise<Employee> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmployeeCount(filters: Record<string, any> = {}): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
const fa = toFrappeFilterArray(filters);
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
}
|
||||||
|
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Employee?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterService = new MasterService();
|
||||||
|
export default masterService;
|
||||||
127
pm_app/src/services/materialRequestService.ts
Normal file
127
pm_app/src/services/materialRequestService.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||||
|
|
||||||
|
const RESOURCE = 'Material%20Request';
|
||||||
|
|
||||||
|
export interface MaterialRequestItem {
|
||||||
|
name?: string;
|
||||||
|
item_code?: string;
|
||||||
|
item_name?: string;
|
||||||
|
description?: string;
|
||||||
|
qty?: number;
|
||||||
|
uom?: string;
|
||||||
|
schedule_date?: string;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterialRequest {
|
||||||
|
name: string;
|
||||||
|
docstatus?: number;
|
||||||
|
status?: string;
|
||||||
|
material_request_type?: string;
|
||||||
|
transaction_date?: string;
|
||||||
|
schedule_date?: string;
|
||||||
|
company?: string;
|
||||||
|
set_warehouse?: string;
|
||||||
|
items?: MaterialRequestItem[];
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaterialRequestService {
|
||||||
|
private async getCSRFToken(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||||
|
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.message) {
|
||||||
|
(window as any).csrf_token = json.message;
|
||||||
|
return json.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders(): Promise<Record<string, string>> {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||||
|
const csrf = await this.getCSRFToken();
|
||||||
|
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const r = await fetch(url, { credentials: 'include', headers, ...opts });
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${r.status}`);
|
||||||
|
if (body.exc) throw new Error(formatFrappeApiError(body) || 'Request failed');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMaterialRequests(
|
||||||
|
params: { filters?: any[]; limit_start?: number; limit_page_length?: number; order_by?: string } = {},
|
||||||
|
): Promise<MaterialRequest[]> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
const fields = [
|
||||||
|
'name', 'material_request_type', 'transaction_date', 'status',
|
||||||
|
'company', 'docstatus', 'creation', 'modified',
|
||||||
|
];
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||||
|
q.set('limit_start', String(params.limit_start ?? 0));
|
||||||
|
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||||
|
q.set('order_by', params.order_by ?? 'creation desc');
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||||
|
return body.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMaterialRequestCount(filters: any[] = []): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||||
|
try {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||||
|
return body.data?.[0]?.count ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMaterialRequest(name: string): Promise<MaterialRequest> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`);
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMaterialRequest(data: Partial<MaterialRequest>): Promise<MaterialRequest> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMaterialRequest(name: string, data: Partial<MaterialRequest>): Promise<MaterialRequest> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitMaterialRequest(name: string): Promise<MaterialRequest> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ docstatus: 1 }),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const materialRequestService = new MaterialRequestService();
|
||||||
|
export default materialRequestService;
|
||||||
145
pm_app/src/services/paymentEntryService.ts
Normal file
145
pm_app/src/services/paymentEntryService.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
export interface PaymentEntryReference {
|
||||||
|
name?: string;
|
||||||
|
reference_doctype?: string;
|
||||||
|
reference_name?: string;
|
||||||
|
due_date?: string;
|
||||||
|
total_amount?: number;
|
||||||
|
outstanding_amount?: number;
|
||||||
|
allocated_amount?: number;
|
||||||
|
exchange_rate?: number;
|
||||||
|
exchange_gain_loss?: number;
|
||||||
|
account?: string;
|
||||||
|
payment_term_outstanding?: number;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentEntry {
|
||||||
|
name?: string;
|
||||||
|
payment_type?: string;
|
||||||
|
posting_date?: string;
|
||||||
|
company?: string;
|
||||||
|
mode_of_payment?: string;
|
||||||
|
party_type?: string;
|
||||||
|
party?: string;
|
||||||
|
party_name?: string;
|
||||||
|
paid_from?: string;
|
||||||
|
paid_from_account_type?: string;
|
||||||
|
paid_from_account_currency?: string;
|
||||||
|
paid_from_account_balance?: number;
|
||||||
|
paid_to?: string;
|
||||||
|
paid_to_account_type?: string;
|
||||||
|
paid_to_account_currency?: string;
|
||||||
|
paid_to_account_balance?: number;
|
||||||
|
paid_amount?: number;
|
||||||
|
received_amount?: number;
|
||||||
|
source_exchange_rate?: number;
|
||||||
|
target_exchange_rate?: number;
|
||||||
|
base_paid_amount?: number;
|
||||||
|
base_received_amount?: number;
|
||||||
|
total_allocated_amount?: number;
|
||||||
|
unallocated_amount?: number;
|
||||||
|
difference_amount?: number;
|
||||||
|
project?: string;
|
||||||
|
cost_center?: string;
|
||||||
|
status?: string;
|
||||||
|
docstatus?: number;
|
||||||
|
remarks?: string;
|
||||||
|
/** Cheque / Reference No (Transaction ID) */
|
||||||
|
reference_no?: string;
|
||||||
|
/** Cheque / Reference Date */
|
||||||
|
reference_date?: string;
|
||||||
|
references?: PaymentEntryReference[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentEntryService {
|
||||||
|
private csrfToken: string | null = null;
|
||||||
|
|
||||||
|
private async getCSRFToken(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||||
|
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.message) { (window as any).csrf_token = json.message; return json.message; }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders(): Promise<Record<string, string>> {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||||
|
const csrf = await this.getCSRFToken();
|
||||||
|
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
|
const r = await fetch(url, { credentials: 'include', ...opts });
|
||||||
|
const body = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
let msg = body?.exc_type || body?.message || `HTTP ${r.status}`;
|
||||||
|
if (body?._server_messages) {
|
||||||
|
try {
|
||||||
|
const msgs = JSON.parse(body._server_messages);
|
||||||
|
const parsed = Array.isArray(msgs) ? msgs.map((m: string) => { try { return JSON.parse(m).message; } catch { return m; } }) : [];
|
||||||
|
if (parsed.length) msg = parsed.join('\n');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentEntries(params: { filters?: any[]; limit_start?: number; limit_page_length?: number } = {}): Promise<PaymentEntry[]> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
const fields = ['name', 'payment_type', 'posting_date', 'party', 'party_name', 'party_type', 'paid_amount', 'received_amount', 'status', 'mode_of_payment', 'company', 'docstatus', 'creation'];
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||||
|
q.set('limit_start', String(params.limit_start ?? 0));
|
||||||
|
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||||
|
q.set('order_by', 'creation desc');
|
||||||
|
const body = await this.fetchJson(`/api/resource/Payment Entry?${q}`);
|
||||||
|
return body.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentEntryCount(filters: any[] = []): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||||
|
try {
|
||||||
|
const body = await this.fetchJson(`/api/resource/Payment%20Entry?${q}`);
|
||||||
|
return body.data?.[0]?.count ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentEntry(name: string): Promise<PaymentEntry> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/Payment Entry/${encodeURIComponent(name)}`);
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPaymentEntry(data: Partial<PaymentEntry>): Promise<PaymentEntry> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const body = await this.fetchJson('/api/resource/Payment Entry', { method: 'POST', headers, body: JSON.stringify({ ...data, doctype: 'Payment Entry' }) });
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePaymentEntry(name: string, data: Partial<PaymentEntry>): Promise<PaymentEntry> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const body = await this.fetchJson(`/api/resource/Payment Entry/${encodeURIComponent(name)}`, { method: 'PUT', headers, body: JSON.stringify(data) });
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPaymentEntry(name: string): Promise<PaymentEntry> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const body = await this.fetchJson(`/api/resource/Payment Entry/${encodeURIComponent(name)}`, { method: 'PUT', headers, body: JSON.stringify({ docstatus: 1 }) });
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentEntryService = new PaymentEntryService();
|
||||||
166
pm_app/src/services/permissionService.ts
Normal file
166
pm_app/src/services/permissionService.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* QuickCreate Permission Service
|
||||||
|
*
|
||||||
|
* Checks if the current user has permission to create records.
|
||||||
|
* Uses custom API: asset_lite.api.user_roles.has_create_permission
|
||||||
|
* which uses ignore_permissions=True to query Custom DocPerm
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Cache for permissions to avoid repeated API calls
|
||||||
|
const permissionCache: Map<string, boolean> = new Map();
|
||||||
|
let userRolesCache: string[] | null = null;
|
||||||
|
let cacheTimestamp: number = 0;
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's roles using custom API
|
||||||
|
* Uses: asset_lite.api.user_roles.get_user_roles
|
||||||
|
*/
|
||||||
|
export const getUserRoles = async (): Promise<string[]> => {
|
||||||
|
// Check cache
|
||||||
|
const now = Date.now();
|
||||||
|
if (userRolesCache && (now - cacheTimestamp) < CACHE_DURATION) {
|
||||||
|
console.log('[PermissionService] Using cached roles:', userRolesCache);
|
||||||
|
return userRolesCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/method/asset_lite.api.user_roles.get_user_roles', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.message && Array.isArray(data.message)) {
|
||||||
|
userRolesCache = data.message;
|
||||||
|
cacheTimestamp = now;
|
||||||
|
console.log('[PermissionService] User roles fetched:', data.message);
|
||||||
|
return data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[PermissionService] Unexpected roles response:', data);
|
||||||
|
return [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PermissionService] Error fetching user roles:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has specific roles using custom API
|
||||||
|
* Uses: asset_lite.api.user_roles.check_has_role
|
||||||
|
*/
|
||||||
|
export const checkHasRole = async (roles: string[]): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
roles: roles.join(',')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.message?.has_role === true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PermissionService] Error checking roles:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has create permission for a doctype
|
||||||
|
* Uses custom API: asset_lite.api.user_roles.has_create_permission
|
||||||
|
* This API uses ignore_permissions=True to query Custom DocPerm
|
||||||
|
*
|
||||||
|
* @param doctype - The doctype to check permission for
|
||||||
|
* @returns Promise<boolean> - true if user can create, false otherwise
|
||||||
|
*/
|
||||||
|
export const hasCreatePermission = async (doctype: string): Promise<boolean> => {
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = `create_${doctype}`;
|
||||||
|
if (permissionCache.has(cacheKey)) {
|
||||||
|
const cached = permissionCache.get(cacheKey)!;
|
||||||
|
console.log(`[PermissionService] ${doctype} (cached): ${cached}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[PermissionService] Checking create permission for: ${doctype}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use custom API that has ignore_permissions=True
|
||||||
|
const response = await fetch('/api/method/asset_lite.api.user_roles.has_create_permission', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ doctype })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`[PermissionService] has_create_permission response for ${doctype}:`, data);
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
const hasPermission = data.message?.has_permission === true;
|
||||||
|
|
||||||
|
if (hasPermission) {
|
||||||
|
console.log(`[PermissionService] ${doctype}: Permission GRANTED via ${data.message?.reason} (role: ${data.message?.role})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[PermissionService] ${doctype}: Permission DENIED - ${data.message?.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
permissionCache.set(cacheKey, hasPermission);
|
||||||
|
|
||||||
|
return hasPermission;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PermissionService] Error checking permission for ${doctype}:`, err);
|
||||||
|
|
||||||
|
// Fallback: Check if System Manager
|
||||||
|
try {
|
||||||
|
const isAdmin = await checkHasRole(['System Manager', 'Administrator']);
|
||||||
|
if (isAdmin) {
|
||||||
|
console.log('[PermissionService] Fallback: User is admin, granting permission');
|
||||||
|
permissionCache.set(cacheKey, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PermissionService] Fallback check failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On error, default to false (safer)
|
||||||
|
permissionCache.set(cacheKey, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear permission cache
|
||||||
|
* Call this when user logs in/out or permissions change
|
||||||
|
*/
|
||||||
|
export const clearPermissionCache = (): void => {
|
||||||
|
permissionCache.clear();
|
||||||
|
userRolesCache = null;
|
||||||
|
cacheTimestamp = 0;
|
||||||
|
console.log('[PermissionService] Cache cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload permissions for multiple doctypes
|
||||||
|
* Useful for initial page load
|
||||||
|
*
|
||||||
|
* @param doctypes - Array of doctypes to preload permissions for
|
||||||
|
*/
|
||||||
|
export const preloadPermissions = async (doctypes: string[]): Promise<void> => {
|
||||||
|
console.log('[PermissionService] Preloading permissions for:', doctypes);
|
||||||
|
await Promise.all(doctypes.map(doctype => hasCreatePermission(doctype)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getUserRoles,
|
||||||
|
checkHasRole,
|
||||||
|
hasCreatePermission,
|
||||||
|
clearPermissionCache,
|
||||||
|
preloadPermissions,
|
||||||
|
};
|
||||||
629
pm_app/src/services/projectService.ts
Normal file
629
pm_app/src/services/projectService.ts
Normal file
@ -0,0 +1,629 @@
|
|||||||
|
import API_CONFIG from '../config/api';
|
||||||
|
import { toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||||
|
|
||||||
|
// ─── Project ─────────────────────────────────────────────────────────────────
|
||||||
|
export interface ProjectUser {
|
||||||
|
user?: string; email?: string; full_name?: string; welcome_email_sent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
name: string;
|
||||||
|
project_name?: string;
|
||||||
|
status?: string;
|
||||||
|
is_active?: string;
|
||||||
|
priority?: string;
|
||||||
|
company?: string;
|
||||||
|
customer?: string;
|
||||||
|
project_type?: string;
|
||||||
|
custom_project_manager?: string;
|
||||||
|
naming_series?: string;
|
||||||
|
expected_start_date?: string;
|
||||||
|
expected_end_date?: string;
|
||||||
|
percent_complete_method?: string;
|
||||||
|
percent_complete?: number;
|
||||||
|
estimated_costing?: number;
|
||||||
|
actual_time?: number;
|
||||||
|
total_costing_amount?: number;
|
||||||
|
total_purchase_cost?: number;
|
||||||
|
total_sales_amount?: number;
|
||||||
|
total_billable_amount?: number;
|
||||||
|
total_billed_amount?: number;
|
||||||
|
gross_margin?: number;
|
||||||
|
per_gross_margin?: number;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
users?: ProjectUser[];
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
docstatus?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Task — exact fields present in actual Frappe system (verified from JSON) ─
|
||||||
|
// NOTE: type, issue, parent_task are intentionally excluded — they do NOT exist
|
||||||
|
// in this system's tabTask database table.
|
||||||
|
export interface Task {
|
||||||
|
name: string;
|
||||||
|
subject?: string;
|
||||||
|
project?: string;
|
||||||
|
status?: string; // Open | Working | Completed | Cancelled | Overdue
|
||||||
|
priority?: string; // Low | Medium | High | Urgent
|
||||||
|
task_weight?: number;
|
||||||
|
is_group?: number;
|
||||||
|
is_template?: number;
|
||||||
|
is_milestone?: number;
|
||||||
|
exp_start_date?: string;
|
||||||
|
exp_end_date?: string;
|
||||||
|
expected_time?: number;
|
||||||
|
actual_time?: number;
|
||||||
|
progress?: number;
|
||||||
|
completed_on?: string;
|
||||||
|
custom_risk?: string;
|
||||||
|
custom_action?: string;
|
||||||
|
custom_task_obstacle?: string;
|
||||||
|
custom_assign_to?: string;
|
||||||
|
total_costing_amount?: number;
|
||||||
|
total_billing_amount?: number;
|
||||||
|
company?: string;
|
||||||
|
description?: string;
|
||||||
|
depends_on_tasks?: string;
|
||||||
|
depends_on?: Array<{ task?: string; subject?: string }>;
|
||||||
|
_assign?: string;
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
docstatus?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project Template ─────────────────────────────────────────────────────────
|
||||||
|
// Frappe's "Project Template Task" child doctype fields (confirmed from Frappe desk):
|
||||||
|
// task - Link to Task (mandatory, filtered by is_template=1)
|
||||||
|
// subject - Data, auto-fetched from task.subject (read-only)
|
||||||
|
// duration - Int (days, optional)
|
||||||
|
// The user must select (or create) a Task with is_template=1 for each row.
|
||||||
|
export interface ProjectTemplateTask {
|
||||||
|
task?: string; // Link to Task doctype (is_template=1 tasks)
|
||||||
|
subject?: string; // Auto-fetched from task.subject (read-only)
|
||||||
|
duration?: number; // Duration in days
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTemplate {
|
||||||
|
name: string;
|
||||||
|
project_type?: string;
|
||||||
|
tasks?: ProjectTemplateTask[];
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Timesheet Detail child table — exact fields from Frappe system ───────────
|
||||||
|
export interface TimesheetDetail {
|
||||||
|
name?: string;
|
||||||
|
activity_type?: string;
|
||||||
|
from_time?: string;
|
||||||
|
to_time?: string;
|
||||||
|
hours?: number;
|
||||||
|
billing_hours?: number;
|
||||||
|
billing_rate?: number;
|
||||||
|
billing_amount?: number;
|
||||||
|
costing_rate?: number;
|
||||||
|
costing_amount?: number;
|
||||||
|
project?: string;
|
||||||
|
task?: string;
|
||||||
|
note?: string;
|
||||||
|
is_billable?: number;
|
||||||
|
completed?: number;
|
||||||
|
idx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Timesheet — exact fields from actual Frappe system data ─────────────────
|
||||||
|
export interface Timesheet {
|
||||||
|
name: string;
|
||||||
|
naming_series?: string;
|
||||||
|
status?: string; // Draft | Submitted | Cancelled
|
||||||
|
currency?: string;
|
||||||
|
exchange_rate?: number;
|
||||||
|
// Header link fields (ERPNext: company + parent_project; optional custom aliases)
|
||||||
|
employee?: string;
|
||||||
|
customer?: string;
|
||||||
|
parent_project?: string; // Frappe fieldname for header "Project"
|
||||||
|
project?: string; // Legacy / display alias only — prefer parent_project for API
|
||||||
|
company?: string; // Company (labeled Hospital in this app)
|
||||||
|
hospital?: string; // Custom field on some sites — avoid relying on this for ERPNext core
|
||||||
|
note?: string;
|
||||||
|
// Totals (computed by Frappe)
|
||||||
|
total_hours?: number;
|
||||||
|
total_billable_hours?: number;
|
||||||
|
total_billable_amount?: number;
|
||||||
|
total_costing_amount?: number;
|
||||||
|
time_logs?: TimesheetDetail[];
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
docstatus?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Activity Type ────────────────────────────────────────────────────────────
|
||||||
|
// Frappe Activity Type fields (from standard ERPNext Activity Type doctype):
|
||||||
|
// activity_type - Data (mandatory) — autoname: Frappe sets name = activity_type
|
||||||
|
// billing_rate - Currency (NOT default_billing_rate)
|
||||||
|
// costing_rate - Currency (NOT default_costing_rate)
|
||||||
|
// disabled - Check
|
||||||
|
// autoname = "field:activity_type" → name column = activity_type value
|
||||||
|
export interface ActivityType {
|
||||||
|
name: string;
|
||||||
|
activity_type?: string; // The display label (same as name due to autoname)
|
||||||
|
billing_rate?: number; // Standard field name in Frappe
|
||||||
|
costing_rate?: number; // Standard field name in Frappe
|
||||||
|
disabled?: number;
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectListParams {
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
/** Extra Frappe filter rows (e.g. child table: [['Timesheet Detail','project','=', name]]) */
|
||||||
|
appendFilters?: any[][];
|
||||||
|
fields?: string[];
|
||||||
|
limit_start?: number;
|
||||||
|
limit_page_length?: number;
|
||||||
|
order_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Custom child tables on Task / Project (desk: `custom_task_updates`, `custom_project_updates`). */
|
||||||
|
export const CUSTOM_TASK_UPDATES = 'custom_task_updates';
|
||||||
|
export const CUSTOM_PROJECT_UPDATES = 'custom_project_updates';
|
||||||
|
|
||||||
|
export type ProgressUpdateChildRow = {
|
||||||
|
name?: string;
|
||||||
|
update_?: string;
|
||||||
|
date?: string;
|
||||||
|
task?: string;
|
||||||
|
idx?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build Frappe child-table payload; optional `fillTaskLink` sets hidden Task link on every row (Task form). */
|
||||||
|
export function serializeProgressUpdateRows(
|
||||||
|
rows: ProgressUpdateChildRow[],
|
||||||
|
options?: { fillTaskLink?: string },
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const fill = options?.fillTaskLink;
|
||||||
|
return rows
|
||||||
|
.filter(r => {
|
||||||
|
const u = String(r.update_ || '').trim();
|
||||||
|
const d = String(r.date || '').trim();
|
||||||
|
const t = String(r.task || '').trim();
|
||||||
|
return u || d || t;
|
||||||
|
})
|
||||||
|
.map((r, i) => {
|
||||||
|
const row: Record<string, unknown> = { idx: i + 1, update_: r.update_ || '' };
|
||||||
|
if (r.date) row.date = r.date;
|
||||||
|
if (r.name) row.name = r.name;
|
||||||
|
const taskVal = fill ?? r.task;
|
||||||
|
if (taskVal) row.task = taskVal;
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Service class ────────────────────────────────────────────────────────────
|
||||||
|
class ProjectService {
|
||||||
|
private readonly baseURL = API_CONFIG.BASE_URL;
|
||||||
|
|
||||||
|
private clearCsrfTokenCache(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
delete (window as any).csrf_token;
|
||||||
|
const fr = (window as any).frappe;
|
||||||
|
if (fr && typeof fr === 'object' && 'csrf_token' in fr) {
|
||||||
|
try {
|
||||||
|
delete fr.csrf_token;
|
||||||
|
} catch {
|
||||||
|
fr.csrf_token = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCSRFToken(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
// Frappe sets csrf_token on window directly OR via the frappe global object
|
||||||
|
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||||
|
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||||
|
// Fallback: fetch it from the API (happens when SPA loads before Frappe injects it)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.baseURL}/api/method/frappe.sessions.get_csrf_token`, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.message) {
|
||||||
|
(window as any).csrf_token = json.message;
|
||||||
|
return json.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeHeaders(base: Record<string, string>, extra?: HeadersInit): Record<string, string> {
|
||||||
|
const out = { ...base };
|
||||||
|
if (!extra) return out;
|
||||||
|
if (extra instanceof Headers) {
|
||||||
|
extra.forEach((v, k) => {
|
||||||
|
out[k] = v;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (Array.isArray(extra)) {
|
||||||
|
for (const [k, v] of extra) out[k] = v;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return { ...out, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect CSRF failure so we can clear cache and retry once with a fresh token. */
|
||||||
|
private isFrappeCsrfError(body: unknown): boolean {
|
||||||
|
if (!body || typeof body !== 'object') return false;
|
||||||
|
const b = body as Record<string, unknown>;
|
||||||
|
if (b.exc_type === 'CSRFTokenError') return true;
|
||||||
|
const msg = typeof b.message === 'string' ? b.message : '';
|
||||||
|
if (/csrf/i.test(msg)) return true;
|
||||||
|
if (b._server_messages && typeof b._server_messages === 'string') {
|
||||||
|
try {
|
||||||
|
const msgs = JSON.parse(b._server_messages as string);
|
||||||
|
const first = msgs[0];
|
||||||
|
const inner = typeof first === 'string' ? JSON.parse(first) : first;
|
||||||
|
const m = typeof inner?.message === 'string' ? inner.message : '';
|
||||||
|
if (/invalid request/i.test(m) && b.exc_type === 'CSRFTokenError') return true;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
if (/CSRFTokenError|csrf/i.test(b._server_messages as string)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders(): Promise<Record<string, string>> {
|
||||||
|
const h: Record<string, string> = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||||
|
const csrf = await this.getCSRFToken();
|
||||||
|
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse Frappe JSON error responses into a human-readable message. */
|
||||||
|
private async parseFrappeError(response: Response): Promise<Error> {
|
||||||
|
const statusMessages: Record<number, string> = {
|
||||||
|
403: 'Permission denied. Your role does not have access to this DocType.',
|
||||||
|
417: 'Validation error. Check field values or role permissions for this DocType.',
|
||||||
|
404: 'Record not found.',
|
||||||
|
500: 'Server error. Check that all field names are correct.',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
// Frappe _server_messages is a double-serialized JSON array
|
||||||
|
if (body._server_messages) {
|
||||||
|
try {
|
||||||
|
const msgs = JSON.parse(body._server_messages);
|
||||||
|
const parsed = typeof msgs[0] === 'string' ? JSON.parse(msgs[0]) : msgs[0];
|
||||||
|
if (parsed?.message) return new Error(parsed.message);
|
||||||
|
} catch { /* ignore parse error */ }
|
||||||
|
}
|
||||||
|
// Direct message field
|
||||||
|
if (body.message && typeof body.message === 'string' && body.message.trim()) {
|
||||||
|
return new Error(body.message);
|
||||||
|
}
|
||||||
|
// exc_type with exception string (e.g. OperationalError, MandatoryError)
|
||||||
|
if (body.exc_type) {
|
||||||
|
const detail = body.exc ? body.exc.split('\n').slice(-2).join(' ').trim() : '';
|
||||||
|
return new Error(`${body.exc_type}: ${detail || statusMessages[response.status] || response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return new Error(statusMessages[response.status] || `HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(url: string, options?: RequestInit): Promise<any> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: 'include', ...options,
|
||||||
|
headers: { ...await this.getHeaders(), ...(options?.headers || {}) },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw await this.parseFrappeError(response.clone());
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project ──────────────────────────────────────────────────────────────
|
||||||
|
async getProjects(params: ProjectListParams = {}): Promise<{ data: Project[] }> {
|
||||||
|
const { filters = {}, fields = ['name','project_name','status','priority','company','customer','expected_start_date','expected_end_date','percent_complete','actual_time','creation','modified','owner'], limit_start = 0, limit_page_length = 20, order_by = 'modified desc' } = params;
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
q.set('limit_start', String(limit_start));
|
||||||
|
q.set('limit_page_length', String(limit_page_length));
|
||||||
|
q.set('order_by', order_by);
|
||||||
|
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project?${q}`);
|
||||||
|
return { data: r.data || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProject(name: string): Promise<Project> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project/${encodeURIComponent(name)}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectCount(filters: Record<string, any> = {}): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||||
|
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Project?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(data: Partial<Project>): Promise<Project> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProject(name: string, data: Partial<Project>): Promise<Project> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project Template ─────────────────────────────────────────────────────
|
||||||
|
async getProjectTemplates(params: ProjectListParams = {}): Promise<{ data: ProjectTemplate[] }> {
|
||||||
|
const { filters = {}, limit_start = 0, limit_page_length = 50, order_by = 'name asc' } = params;
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['name', 'project_type', 'creation', 'modified']));
|
||||||
|
q.set('limit_start', String(limit_start));
|
||||||
|
q.set('limit_page_length', String(limit_page_length));
|
||||||
|
q.set('order_by', order_by);
|
||||||
|
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||||
|
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template?${q}`); return { data: r.data || [] }; }
|
||||||
|
catch { return { data: [] }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectTemplate(name: string): Promise<ProjectTemplate> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template/${encodeURIComponent(name)}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProjectTemplate(data: Partial<ProjectTemplate>): Promise<ProjectTemplate> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProjectTemplate(name: string, data: Partial<ProjectTemplate>): Promise<ProjectTemplate> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectTemplateCount(filters: Record<string, any> = {}): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
const fa = toFrappeFilterArray(filters);
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
}
|
||||||
|
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tasks ────────────────────────────────────────────────────────────────
|
||||||
|
// Only fields confirmed present in this system's tabTask table (from actual JSON).
|
||||||
|
// type / issue / parent_task are deliberately omitted — they don't exist here.
|
||||||
|
private readonly TASK_FIELDS = [
|
||||||
|
'name', 'subject', 'project', 'status', 'priority',
|
||||||
|
'task_weight', 'is_group', 'is_template', 'is_milestone',
|
||||||
|
'expected_time', 'actual_time', 'progress',
|
||||||
|
'exp_start_date', 'exp_end_date', 'parent_task',
|
||||||
|
'completed_on',
|
||||||
|
'custom_risk', 'custom_action', 'custom_task_obstacle', 'custom_assign_to',
|
||||||
|
'total_costing_amount', 'total_billing_amount', 'company',
|
||||||
|
'depends_on_tasks', '_assign', 'owner', 'creation', 'modified',
|
||||||
|
];
|
||||||
|
|
||||||
|
async getTasks(params: ProjectListParams = {}): Promise<{ data: Task[] }> {
|
||||||
|
const { filters = {}, fields = this.TASK_FIELDS, limit_start = 0, limit_page_length = 50, order_by = 'creation desc' } = params;
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
q.set('limit_start', String(limit_start));
|
||||||
|
q.set('limit_page_length', String(limit_page_length));
|
||||||
|
q.set('order_by', order_by);
|
||||||
|
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Task?${q}`);
|
||||||
|
return { data: r.data || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTasksForProject(projectName: string, params?: { limit_start?: number; limit_page_length?: number }): Promise<{ data: Task[] }> {
|
||||||
|
return this.getTasks({ ...params, filters: { project: projectName }, limit_page_length: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTask(name: string): Promise<Task> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Task/${encodeURIComponent(name)}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTask(data: Partial<Task>): Promise<Task> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Task`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(name: string, data: Partial<Task>): Promise<Task> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Task/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTaskCount(filters: Record<string, any> = {}): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||||
|
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Task?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Timesheets ──────────────────────────────────────────────────────────
|
||||||
|
// Fields match the actual Frappe Timesheet doctype (verified against system JSON)
|
||||||
|
// Note: employee/posting_date are NOT required in this system's configuration
|
||||||
|
async getTimesheets(params: ProjectListParams = {}): Promise<{ data: Timesheet[] }> {
|
||||||
|
const {
|
||||||
|
filters = {},
|
||||||
|
appendFilters = [],
|
||||||
|
fields = [
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
'docstatus',
|
||||||
|
'currency',
|
||||||
|
'employee',
|
||||||
|
'customer',
|
||||||
|
'company',
|
||||||
|
'total_hours',
|
||||||
|
'total_billable_hours',
|
||||||
|
'total_billable_amount',
|
||||||
|
'total_costing_amount',
|
||||||
|
'creation',
|
||||||
|
'modified',
|
||||||
|
'owner',
|
||||||
|
],
|
||||||
|
limit_start = 0,
|
||||||
|
limit_page_length = 20,
|
||||||
|
order_by = 'creation desc',
|
||||||
|
} = params;
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
q.set('limit_start', String(limit_start));
|
||||||
|
q.set('limit_page_length', String(limit_page_length));
|
||||||
|
q.set('order_by', order_by);
|
||||||
|
let fa = toFrappeFilterArray(filters);
|
||||||
|
if (appendFilters.length) fa = [...fa, ...appendFilters];
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet?${q}`);
|
||||||
|
return { data: r.data || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTimesheetsForProject(projectName: string, params?: { limit_start?: number; limit_page_length?: number }): Promise<{ data: Timesheet[] }> {
|
||||||
|
const { limit_start = 0, limit_page_length = 50 } = params || {};
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('filters', JSON.stringify([['Timesheet Detail', 'project', '=', projectName]]));
|
||||||
|
q.set('fields', JSON.stringify(['name','status','docstatus','currency','total_hours','total_billable_hours','total_billable_amount','total_costing_amount','creation','modified']));
|
||||||
|
q.set('limit_start', String(limit_start));
|
||||||
|
q.set('limit_page_length', String(limit_page_length));
|
||||||
|
q.set('order_by', 'creation desc');
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet?${q}`);
|
||||||
|
return { data: r.data || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTimesheet(name: string): Promise<Timesheet> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTimesheet(data: Partial<Timesheet>): Promise<Timesheet> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTimesheet(name: string, data: Partial<Timesheet>): Promise<Timesheet> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitTimesheet(name: string): Promise<Timesheet> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify({ docstatus: 1 }) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelTimesheet(name: string): Promise<Timesheet> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify({ docstatus: 2 }) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTimesheetCount(filters: Record<string, any> = {}, appendFilters: any[] = []): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
let fa = toFrappeFilterArray(filters);
|
||||||
|
if (appendFilters.length) fa = [...fa, ...appendFilters];
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Activity Type ────────────────────────────────────────────────────────
|
||||||
|
async getActivityTypes(params: ProjectListParams = {}): Promise<{ data: ActivityType[] }> {
|
||||||
|
const { filters = {}, limit_start = 0, limit_page_length = 50, order_by = 'name asc' } = params;
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
// Correct field names from standard ERPNext Activity Type doctype
|
||||||
|
q.set('fields', JSON.stringify(['name', 'activity_type', 'billing_rate', 'costing_rate', 'disabled', 'creation', 'modified']));
|
||||||
|
q.set('limit_start', String(limit_start));
|
||||||
|
q.set('limit_page_length', String(limit_page_length));
|
||||||
|
q.set('order_by', order_by);
|
||||||
|
if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
|
||||||
|
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type?${q}`); return { data: r.data || [] }; }
|
||||||
|
catch { return { data: [] }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActivityType(name: string): Promise<ActivityType> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type/${encodeURIComponent(name)}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createActivityType(data: Partial<ActivityType>): Promise<ActivityType> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateActivityType(name: string, data: Partial<ActivityType>): Promise<ActivityType> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActivityTypeCount(filters: Record<string, any> = {}): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
const fa = toFrappeFilterArray(filters);
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
}
|
||||||
|
try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Counts for module page ───────────────────────────────────────────────
|
||||||
|
async getModuleCounts(): Promise<{ projects: number; tasks: number; timesheets: number }> {
|
||||||
|
const [projects, tasks, timesheets] = await Promise.all([
|
||||||
|
this.getProjectCount({ status: 'Open' }),
|
||||||
|
this.getTaskCount({}),
|
||||||
|
this.getTimesheetCount({}),
|
||||||
|
]);
|
||||||
|
return { projects, tasks, timesheets };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project-level view: rows stored on Project plus rows from each Task’s `custom_task_updates`.
|
||||||
|
* Capped task scan to avoid huge fan-out.
|
||||||
|
*/
|
||||||
|
async getMergedProjectProgressUpdates(
|
||||||
|
projectName: string,
|
||||||
|
opts?: { maxTasks?: number },
|
||||||
|
): Promise<Array<ProgressUpdateChildRow & { _source: 'project' | 'task' }>> {
|
||||||
|
const maxTasks = opts?.maxTasks ?? 120;
|
||||||
|
const proj = await this.getProject(projectName);
|
||||||
|
const { data: tasks } = await this.getTasksForProject(projectName, { limit_page_length: maxTasks });
|
||||||
|
const fromProject: Array<ProgressUpdateChildRow & { _source: 'project' | 'task' }> = (
|
||||||
|
(proj as any)[CUSTOM_PROJECT_UPDATES] || []
|
||||||
|
).map((r: ProgressUpdateChildRow) => ({ ...r, _source: 'project' as const }));
|
||||||
|
const names = tasks.map(t => t.name).filter(Boolean).slice(0, maxTasks);
|
||||||
|
const taskDocs = await Promise.all(names.map(n => this.getTask(n).catch(() => null)));
|
||||||
|
const fromTasks: Array<ProgressUpdateChildRow & { _source: 'project' | 'task' }> = [];
|
||||||
|
for (const doc of taskDocs) {
|
||||||
|
if (!doc) continue;
|
||||||
|
const rows = (doc as any)[CUSTOM_TASK_UPDATES] || [];
|
||||||
|
for (const r of rows) {
|
||||||
|
fromTasks.push({
|
||||||
|
...r,
|
||||||
|
task: r.task || doc.name,
|
||||||
|
_source: 'task',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const key = (r: ProgressUpdateChildRow) => String(r.date || '').slice(0, 10) || '';
|
||||||
|
return [...fromTasks, ...fromProject].sort((a, b) => key(b).localeCompare(key(a)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectService = new ProjectService();
|
||||||
|
export default projectService;
|
||||||
143
pm_app/src/services/purchaseOrderService.ts
Normal file
143
pm_app/src/services/purchaseOrderService.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||||
|
|
||||||
|
const RESOURCE = 'Purchase%20Order';
|
||||||
|
|
||||||
|
export interface PurchaseOrderItem {
|
||||||
|
name?: string;
|
||||||
|
item_code?: string;
|
||||||
|
item_name?: string;
|
||||||
|
description?: string;
|
||||||
|
qty?: number;
|
||||||
|
rate?: number;
|
||||||
|
amount?: number;
|
||||||
|
uom?: string;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseTaxCharge {
|
||||||
|
name?: string;
|
||||||
|
charge_type?: string;
|
||||||
|
account_head?: string;
|
||||||
|
tax_amount?: number;
|
||||||
|
rate?: number;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOrder {
|
||||||
|
name: string;
|
||||||
|
docstatus?: number;
|
||||||
|
status?: string;
|
||||||
|
supplier?: string;
|
||||||
|
supplier_name?: string;
|
||||||
|
transaction_date?: string;
|
||||||
|
schedule_date?: string;
|
||||||
|
company?: string;
|
||||||
|
currency?: string;
|
||||||
|
grand_total?: number;
|
||||||
|
net_total?: number;
|
||||||
|
total_taxes_and_charges?: number;
|
||||||
|
items?: PurchaseOrderItem[];
|
||||||
|
taxes?: PurchaseTaxCharge[];
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PurchaseOrderService {
|
||||||
|
private async getCSRFToken(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||||
|
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.message) {
|
||||||
|
(window as any).csrf_token = json.message;
|
||||||
|
return json.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders(): Promise<Record<string, string>> {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||||
|
const csrf = await this.getCSRFToken();
|
||||||
|
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const r = await fetch(url, { credentials: 'include', headers, ...opts });
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${r.status}`);
|
||||||
|
if (body.exc) throw new Error(formatFrappeApiError(body) || 'Request failed');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPurchaseOrders(
|
||||||
|
params: { filters?: any[]; limit_start?: number; limit_page_length?: number; order_by?: string } = {},
|
||||||
|
): Promise<PurchaseOrder[]> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
const fields = [
|
||||||
|
'name', 'supplier', 'supplier_name', 'transaction_date', 'schedule_date',
|
||||||
|
'status', 'grand_total', 'currency', 'docstatus', 'company', 'creation', 'modified',
|
||||||
|
];
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||||
|
q.set('limit_start', String(params.limit_start ?? 0));
|
||||||
|
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||||
|
q.set('order_by', params.order_by ?? 'creation desc');
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||||
|
return body.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPurchaseOrderCount(filters: any[] = []): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||||
|
try {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||||
|
return body.data?.[0]?.count ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPurchaseOrder(name: string): Promise<PurchaseOrder> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`);
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPurchaseOrder(data: Partial<PurchaseOrder>): Promise<PurchaseOrder> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePurchaseOrder(name: string, data: Partial<PurchaseOrder>): Promise<PurchaseOrder> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPurchaseOrder(name: string): Promise<PurchaseOrder> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ docstatus: 1 }),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseOrderService = new PurchaseOrderService();
|
||||||
|
export default purchaseOrderService;
|
||||||
142
pm_app/src/services/purchaseReceiptService.ts
Normal file
142
pm_app/src/services/purchaseReceiptService.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||||
|
|
||||||
|
const RESOURCE = 'Purchase%20Receipt';
|
||||||
|
|
||||||
|
export interface PurchaseReceiptItem {
|
||||||
|
name?: string;
|
||||||
|
item_code?: string;
|
||||||
|
item_name?: string;
|
||||||
|
description?: string;
|
||||||
|
qty?: number;
|
||||||
|
rate?: number;
|
||||||
|
amount?: number;
|
||||||
|
uom?: string;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseTaxCharge {
|
||||||
|
name?: string;
|
||||||
|
charge_type?: string;
|
||||||
|
account_head?: string;
|
||||||
|
tax_amount?: number;
|
||||||
|
rate?: number;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseReceipt {
|
||||||
|
name: string;
|
||||||
|
docstatus?: number;
|
||||||
|
status?: string;
|
||||||
|
supplier?: string;
|
||||||
|
supplier_name?: string;
|
||||||
|
posting_date?: string;
|
||||||
|
company?: string;
|
||||||
|
currency?: string;
|
||||||
|
grand_total?: number;
|
||||||
|
net_total?: number;
|
||||||
|
total_taxes_and_charges?: number;
|
||||||
|
items?: PurchaseReceiptItem[];
|
||||||
|
taxes?: PurchaseTaxCharge[];
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PurchaseReceiptService {
|
||||||
|
private async getCSRFToken(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||||
|
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.message) {
|
||||||
|
(window as any).csrf_token = json.message;
|
||||||
|
return json.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders(): Promise<Record<string, string>> {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||||
|
const csrf = await this.getCSRFToken();
|
||||||
|
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const r = await fetch(url, { credentials: 'include', headers, ...opts });
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${r.status}`);
|
||||||
|
if (body.exc) throw new Error(formatFrappeApiError(body) || 'Request failed');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPurchaseReceipts(
|
||||||
|
params: { filters?: any[]; limit_start?: number; limit_page_length?: number; order_by?: string } = {},
|
||||||
|
): Promise<PurchaseReceipt[]> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
const fields = [
|
||||||
|
'name', 'supplier', 'supplier_name', 'posting_date', 'status',
|
||||||
|
'grand_total', 'currency', 'docstatus', 'company', 'creation', 'modified',
|
||||||
|
];
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||||
|
q.set('limit_start', String(params.limit_start ?? 0));
|
||||||
|
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||||
|
q.set('order_by', params.order_by ?? 'creation desc');
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||||
|
return body.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPurchaseReceiptCount(filters: any[] = []): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||||
|
try {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||||
|
return body.data?.[0]?.count ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPurchaseReceipt(name: string): Promise<PurchaseReceipt> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`);
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPurchaseReceipt(data: Partial<PurchaseReceipt>): Promise<PurchaseReceipt> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePurchaseReceipt(name: string, data: Partial<PurchaseReceipt>): Promise<PurchaseReceipt> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPurchaseReceipt(name: string): Promise<PurchaseReceipt> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ docstatus: 1 }),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseReceiptService = new PurchaseReceiptService();
|
||||||
|
export default purchaseReceiptService;
|
||||||
292
pm_app/src/services/salesInvoiceService.ts
Normal file
292
pm_app/src/services/salesInvoiceService.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||||
|
|
||||||
|
// ─── Interfaces ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SalesInvoiceItem {
|
||||||
|
name?: string;
|
||||||
|
item_code?: string;
|
||||||
|
item_name?: string;
|
||||||
|
description?: string;
|
||||||
|
qty?: number;
|
||||||
|
rate?: number;
|
||||||
|
amount?: number;
|
||||||
|
uom?: string;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesInvoiceTimesheet {
|
||||||
|
name?: string;
|
||||||
|
time_sheet?: string;
|
||||||
|
billing_hours?: number;
|
||||||
|
billing_amount?: number;
|
||||||
|
activity_type?: string;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesInvoice {
|
||||||
|
name: string;
|
||||||
|
naming_series?: string;
|
||||||
|
customer?: string;
|
||||||
|
customer_name?: string;
|
||||||
|
posting_date?: string;
|
||||||
|
posting_time?: string;
|
||||||
|
currency?: string;
|
||||||
|
status?: string;
|
||||||
|
docstatus?: number;
|
||||||
|
grand_total?: number;
|
||||||
|
net_total?: number;
|
||||||
|
total?: number;
|
||||||
|
total_taxes_and_charges?: number;
|
||||||
|
rounded_total?: number;
|
||||||
|
outstanding_amount?: number;
|
||||||
|
total_billing_hours?: number;
|
||||||
|
total_billing_amount?: number;
|
||||||
|
company?: string;
|
||||||
|
items?: SalesInvoiceItem[];
|
||||||
|
timesheets?: SalesInvoiceTimesheet[];
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesInvoiceListParams {
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
fields?: string[];
|
||||||
|
limit_start?: number;
|
||||||
|
limit_page_length?: number;
|
||||||
|
order_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Service ─────────────────────────────────────────────────────────────────
|
||||||
|
class SalesInvoiceService {
|
||||||
|
private baseURL = '';
|
||||||
|
|
||||||
|
private async getCSRFToken(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||||
|
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||||
|
if (res.ok) { const json = await res.json(); if (json.message) { (window as any).csrf_token = json.message; return json.message; } }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders(): Promise<Record<string, string>> {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||||
|
const csrf = await this.getCSRFToken();
|
||||||
|
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeErrorMessage(msg: string): string {
|
||||||
|
if (!msg) return 'Something went wrong.';
|
||||||
|
// Strip HTML if any
|
||||||
|
const noHtml = msg.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
// Remove python tracebacks / long stacks
|
||||||
|
const tbIdx = noHtml.toLowerCase().indexOf('traceback');
|
||||||
|
const cleaned = tbIdx >= 0 ? noHtml.slice(0, tbIdx).trim() : noHtml;
|
||||||
|
// Trim very long errors
|
||||||
|
if (cleaned.length > 220) return `${cleaned.slice(0, 220)}…`;
|
||||||
|
return cleaned || 'Something went wrong.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFrappeError(body: any): string {
|
||||||
|
if (body?.exc_type === 'ValidationError') return body.exc || body.message || 'Validation error';
|
||||||
|
if (body?.message) return this.sanitizeErrorMessage(String(body.message));
|
||||||
|
if (body?._server_messages) {
|
||||||
|
try {
|
||||||
|
const msgs = JSON.parse(body._server_messages);
|
||||||
|
const first = msgs?.[0];
|
||||||
|
const parsed = typeof first === 'string' ? JSON.parse(first) : first;
|
||||||
|
const m = parsed?.message || first || body._server_messages;
|
||||||
|
return this.sanitizeErrorMessage(String(m));
|
||||||
|
} catch {
|
||||||
|
return this.sanitizeErrorMessage(String(body._server_messages));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const r = await fetch(url, { credentials: 'include', headers, ...opts });
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(this.parseFrappeError(body));
|
||||||
|
if (body.exc) throw new Error(this.parseFrappeError(body));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSalesInvoices(params: SalesInvoiceListParams = {}): Promise<{ data: SalesInvoice[] }> {
|
||||||
|
const {
|
||||||
|
filters = {},
|
||||||
|
fields = ['name','status','customer','customer_name','posting_date','currency','grand_total','outstanding_amount','docstatus','creation'],
|
||||||
|
limit_start = 0,
|
||||||
|
limit_page_length = 20,
|
||||||
|
order_by = 'creation desc',
|
||||||
|
} = params;
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
q.set('limit_start', String(limit_start));
|
||||||
|
q.set('limit_page_length', String(limit_page_length));
|
||||||
|
q.set('order_by', order_by);
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
const fa = toFrappeFilterArray(filters);
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
}
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales Invoice?${q}`);
|
||||||
|
return { data: r.data || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Sales Invoices linked to the given Sales Orders via Sales Invoice Item.sales_order.
|
||||||
|
* Uses frappe.client.get_list to support child-table filters.
|
||||||
|
*/
|
||||||
|
async getSalesInvoicesBySalesOrders(args: {
|
||||||
|
salesOrders: string[];
|
||||||
|
limit?: number;
|
||||||
|
orderBy?: string;
|
||||||
|
fields?: string[];
|
||||||
|
}): Promise<SalesInvoice[]> {
|
||||||
|
const {
|
||||||
|
salesOrders,
|
||||||
|
limit = 1000,
|
||||||
|
orderBy = 'posting_date asc',
|
||||||
|
fields = ['name', 'posting_date', 'customer', 'customer_name', 'currency', 'grand_total', 'docstatus', 'status'],
|
||||||
|
} = args;
|
||||||
|
if (!salesOrders.length) return [];
|
||||||
|
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/method/frappe.client.get_list`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
doctype: 'Sales Invoice',
|
||||||
|
fields,
|
||||||
|
filters: [['Sales Invoice Item', 'sales_order', 'in', salesOrders]],
|
||||||
|
order_by: orderBy,
|
||||||
|
limit_page_length: limit,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return (r.message || []) as SalesInvoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoices tied to a Project via document or line-level `project` (not only via Sales Order).
|
||||||
|
* Merges header `project` and `Sales Invoice Item.project` matches — deduped by invoice name.
|
||||||
|
*/
|
||||||
|
async getSalesInvoicesLinkedToProject(args: {
|
||||||
|
project: string;
|
||||||
|
limit?: number;
|
||||||
|
orderBy?: string;
|
||||||
|
fields?: string[];
|
||||||
|
}): Promise<SalesInvoice[]> {
|
||||||
|
const {
|
||||||
|
project,
|
||||||
|
limit = 1000,
|
||||||
|
orderBy = 'posting_date asc',
|
||||||
|
fields = ['name', 'posting_date', 'customer', 'customer_name', 'currency', 'grand_total', 'docstatus', 'status'],
|
||||||
|
} = args;
|
||||||
|
if (!project?.trim()) return [];
|
||||||
|
|
||||||
|
const listPayload = (filters: any[]) =>
|
||||||
|
this.fetchJson(`${this.baseURL}/api/method/frappe.client.get_list`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
doctype: 'Sales Invoice',
|
||||||
|
fields,
|
||||||
|
filters,
|
||||||
|
order_by: orderBy,
|
||||||
|
limit_page_length: limit,
|
||||||
|
}),
|
||||||
|
}).then((r: any) => (r.message || []) as SalesInvoice[]).catch(() => [] as SalesInvoice[]);
|
||||||
|
|
||||||
|
const [fromHeader, fromItems] = await Promise.all([
|
||||||
|
listPayload([['project', '=', project]]),
|
||||||
|
listPayload([['Sales Invoice Item', 'project', '=', project]]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byName = new Map<string, SalesInvoice>();
|
||||||
|
for (const inv of fromHeader) if (inv?.name) byName.set(inv.name, inv);
|
||||||
|
for (const inv of fromItems) if (inv?.name) byName.set(inv.name, inv);
|
||||||
|
return [...byName.values()].sort((a, b) =>
|
||||||
|
String(a.posting_date || '').localeCompare(String(b.posting_date || '')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSalesInvoiceCount(filters: Record<string, any> = {}): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (Object.keys(filters).length > 0) {
|
||||||
|
const fa = toFrappeFilterArray(filters);
|
||||||
|
if (fa.length > 0) q.set('filters', JSON.stringify(fa));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales Invoice?${q}`);
|
||||||
|
return r.data?.[0]?.count || 0;
|
||||||
|
} catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSalesInvoice(name: string): Promise<SalesInvoice> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice/${encodeURIComponent(name)}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSalesInvoice(data: Partial<SalesInvoice>): Promise<SalesInvoice> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSalesInvoice(name: string, data: Partial<SalesInvoice>): Promise<SalesInvoice> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directly clear stale "None" link values on SI item rows, bypassing document validation. */
|
||||||
|
async clearItemLinkNones(invoiceName: string): Promise<void> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
// Fetch the raw SI to get child row names
|
||||||
|
const doc = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice/${encodeURIComponent(invoiceName)}`);
|
||||||
|
const items: any[] = doc?.data?.items || [];
|
||||||
|
const staleFields = [
|
||||||
|
'delivery_note', 'dn_detail', 'sales_order', 'so_detail',
|
||||||
|
'against_delivery_note', 'against_sales_order',
|
||||||
|
];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.name) continue;
|
||||||
|
const hasNone = staleFields.some(f => item[f] === 'None' || item[f] === 'none');
|
||||||
|
if (!hasNone) continue;
|
||||||
|
// frappe.client.set_value writes directly to DB without running validate()
|
||||||
|
await this.fetchJson(`${this.baseURL}/api/method/frappe.client.set_value`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
doctype: 'Sales Invoice Item',
|
||||||
|
name: item.name,
|
||||||
|
fieldname: {
|
||||||
|
delivery_note: '', dn_detail: '', sales_order: '', so_detail: '',
|
||||||
|
against_delivery_note: '', against_sales_order: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitSalesInvoice(name: string): Promise<SalesInvoice> {
|
||||||
|
const r = await this.fetchJson(`${this.baseURL}/api/resource/Sales%20Invoice/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ docstatus: 1 }),
|
||||||
|
});
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const salesInvoiceService = new SalesInvoiceService();
|
||||||
|
export default salesInvoiceService;
|
||||||
160
pm_app/src/services/salesOrderService.ts
Normal file
160
pm_app/src/services/salesOrderService.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
|
||||||
|
|
||||||
|
const RESOURCE = 'Sales%20Order';
|
||||||
|
|
||||||
|
export interface SalesOrderItem {
|
||||||
|
name?: string;
|
||||||
|
item_code?: string;
|
||||||
|
item_name?: string;
|
||||||
|
description?: string;
|
||||||
|
qty?: number;
|
||||||
|
rate?: number;
|
||||||
|
amount?: number;
|
||||||
|
uom?: string;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesTaxCharge {
|
||||||
|
name?: string;
|
||||||
|
charge_type?: string;
|
||||||
|
account_head?: string;
|
||||||
|
tax_amount?: number;
|
||||||
|
rate?: number;
|
||||||
|
idx?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesOrder {
|
||||||
|
name: string;
|
||||||
|
docstatus?: number;
|
||||||
|
status?: string;
|
||||||
|
customer?: string;
|
||||||
|
customer_name?: string;
|
||||||
|
transaction_date?: string;
|
||||||
|
delivery_date?: string;
|
||||||
|
company?: string;
|
||||||
|
currency?: string;
|
||||||
|
project?: string;
|
||||||
|
grand_total?: number;
|
||||||
|
net_total?: number;
|
||||||
|
total_taxes_and_charges?: number;
|
||||||
|
billing_status?: string;
|
||||||
|
delivery_status?: string;
|
||||||
|
per_billed?: number;
|
||||||
|
per_delivered?: number;
|
||||||
|
items?: SalesOrderItem[];
|
||||||
|
taxes?: SalesTaxCharge[];
|
||||||
|
owner?: string;
|
||||||
|
creation?: string;
|
||||||
|
modified?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SalesOrderService {
|
||||||
|
private async getCSRFToken(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if ((window as any).csrf_token) return (window as any).csrf_token;
|
||||||
|
if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/method/frappe.sessions.get_csrf_token', { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.message) {
|
||||||
|
(window as any).csrf_token = json.message;
|
||||||
|
return json.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders(): Promise<Record<string, string>> {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
||||||
|
const csrf = await this.getCSRFToken();
|
||||||
|
if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
|
const headers = await this.getHeaders();
|
||||||
|
const r = await fetch(url, { credentials: 'include', headers, ...opts });
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${r.status}`);
|
||||||
|
if (body.exc) throw new Error(formatFrappeApiError(body) || 'Request failed');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSalesOrders(
|
||||||
|
params: { filters?: any[]; limit_start?: number; limit_page_length?: number; order_by?: string } = {},
|
||||||
|
): Promise<SalesOrder[]> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
const fields = [
|
||||||
|
'name', 'customer', 'customer_name', 'transaction_date', 'status', 'grand_total',
|
||||||
|
'currency', 'docstatus', 'project', 'billing_status', 'delivery_status',
|
||||||
|
'creation', 'modified',
|
||||||
|
];
|
||||||
|
q.set('fields', JSON.stringify(fields));
|
||||||
|
if (params.filters?.length) q.set('filters', JSON.stringify(params.filters));
|
||||||
|
q.set('limit_start', String(params.limit_start ?? 0));
|
||||||
|
q.set('limit_page_length', String(params.limit_page_length ?? 20));
|
||||||
|
q.set('order_by', params.order_by ?? 'creation desc');
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||||
|
return body.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSalesOrderCount(filters: any[] = []): Promise<number> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('fields', JSON.stringify(['count(name) as count']));
|
||||||
|
if (filters.length) q.set('filters', JSON.stringify(filters));
|
||||||
|
try {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}?${q}`);
|
||||||
|
return body.data?.[0]?.count ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Document names only (e.g. for project detail / links). */
|
||||||
|
async getSalesOrderNamesForProject(projectName: string, limit = 25): Promise<string[]> {
|
||||||
|
const rows = await this.getSalesOrders({
|
||||||
|
filters: [['Sales Order', 'project', '=', projectName]],
|
||||||
|
limit_start: 0,
|
||||||
|
limit_page_length: limit,
|
||||||
|
order_by: 'modified desc',
|
||||||
|
});
|
||||||
|
return rows.map(r => r.name).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSalesOrder(name: string): Promise<SalesOrder> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`);
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSalesOrder(data: Partial<SalesOrder>): Promise<SalesOrder> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSalesOrder(name: string, data: Partial<SalesOrder>): Promise<SalesOrder> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitSalesOrder(name: string): Promise<SalesOrder> {
|
||||||
|
const body = await this.fetchJson(`/api/resource/${RESOURCE}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ docstatus: 1 }),
|
||||||
|
});
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const salesOrderService = new SalesOrderService();
|
||||||
|
export default salesOrderService;
|
||||||
105
pm_app/src/services/translationService.ts
Normal file
105
pm_app/src/services/translationService.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import apiService from './apiService';
|
||||||
|
|
||||||
|
export interface TranslationRecord {
|
||||||
|
source_text: string;
|
||||||
|
translated_text: string;
|
||||||
|
language: string;
|
||||||
|
context?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationsMap {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch translations from Frappe Translation doctype
|
||||||
|
* Frappe stores translations in the "Translation" doctype, not "Language"
|
||||||
|
* Language doctype is just for enabling languages
|
||||||
|
*/
|
||||||
|
export async function fetchTranslationsFromFrappe(language: string): Promise<TranslationsMap> {
|
||||||
|
try {
|
||||||
|
// Fetch all translation records for the specified language
|
||||||
|
// Frappe uses "Translation" doctype with fields: source_text, translated_text, language, context
|
||||||
|
const response = await apiService.getDoctypeRecords(
|
||||||
|
'Translation',
|
||||||
|
{ language: language },
|
||||||
|
['source_text', 'translated_text', 'context'],
|
||||||
|
10000, // Large limit to get all translations
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const translations: TranslationsMap = {};
|
||||||
|
|
||||||
|
if (response.records && response.records.length > 0) {
|
||||||
|
response.records.forEach((record: any) => {
|
||||||
|
const sourceText = record.source_text;
|
||||||
|
const translatedText = record.translated_text || sourceText;
|
||||||
|
|
||||||
|
// Frappe translations can have:
|
||||||
|
// 1. source_text as the key (e.g., "Dashboard")
|
||||||
|
// 2. context for namespacing (e.g., "common.Dashboard" if context is "common")
|
||||||
|
// 3. Or source_text might already be a nested key (e.g., "common.dashboard")
|
||||||
|
|
||||||
|
if (record.context) {
|
||||||
|
// If context exists, create nested structure: context.source_text
|
||||||
|
const key = `${record.context}.${sourceText}`;
|
||||||
|
translations[key] = translatedText;
|
||||||
|
} else if (sourceText.includes('.')) {
|
||||||
|
// If source_text already contains dots, use it as-is (already nested)
|
||||||
|
translations[sourceText] = translatedText;
|
||||||
|
} else {
|
||||||
|
// Flat structure: use source_text as key
|
||||||
|
translations[sourceText] = translatedText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return translations;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching translations from Frappe:', error);
|
||||||
|
// Return empty object on error, will fall back to static translations
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert flat translation map to nested structure for i18next
|
||||||
|
* Handles keys like "common.dashboard" -> { common: { dashboard: "..." } }
|
||||||
|
* Also handles simple keys without dots
|
||||||
|
*/
|
||||||
|
export function nestTranslations(flatTranslations: TranslationsMap): Record<string, any> {
|
||||||
|
const nested: Record<string, any> = {};
|
||||||
|
|
||||||
|
Object.keys(flatTranslations).forEach((key) => {
|
||||||
|
// If key contains dots, create nested structure
|
||||||
|
if (key.includes('.')) {
|
||||||
|
const parts = key.split('.');
|
||||||
|
let current = nested;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
if (!current[part]) {
|
||||||
|
current[part] = {};
|
||||||
|
}
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
current[parts[parts.length - 1]] = flatTranslations[key];
|
||||||
|
} else {
|
||||||
|
// Simple key without dots - add directly
|
||||||
|
nested[key] = flatTranslations[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translations for a specific language from Frappe
|
||||||
|
* Returns nested translation object ready for i18next
|
||||||
|
*/
|
||||||
|
export async function getFrappeTranslations(language: string): Promise<Record<string, any>> {
|
||||||
|
const flatTranslations = await fetchTranslationsFromFrappe(language);
|
||||||
|
return nestTranslations(flatTranslations);
|
||||||
|
}
|
||||||
|
|
||||||
67
pm_app/src/services/twoFactorService.ts
Normal file
67
pm_app/src/services/twoFactorService.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import apiService from './apiService';
|
||||||
|
|
||||||
|
export interface TwoFactorStatus {
|
||||||
|
enabled_globally: boolean;
|
||||||
|
required_for_user: boolean;
|
||||||
|
method: string | null;
|
||||||
|
otp_app: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = import.meta.env.VITE_FRAPPE_BASE_URL || '';
|
||||||
|
|
||||||
|
export async function fetchTwoFactorStatus(user?: string): Promise<TwoFactorStatus> {
|
||||||
|
const csrf = await apiService.getCSRFTokenForGuest();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (csrf) headers['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseURL}/api/method/project_management.api.two_factor.get_two_factor_status`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(user ? { user } : {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof data.message === 'string'
|
||||||
|
? data.message
|
||||||
|
: data.exc || 'Failed to load two-factor status';
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return data.message as TwoFactorStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetOtpSecret(user: string): Promise<void> {
|
||||||
|
const csrf = await apiService.getCSRFTokenForGuest();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (csrf) headers['X-Frappe-CSRF-Token'] = csrf;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseURL}/api/method/frappe.twofactor.reset_otp_secret`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ user }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof data.message === 'string'
|
||||||
|
? data.message
|
||||||
|
: data.exc || 'Failed to reset OTP secret';
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
190
pm_app/src/services/userProfileService.ts
Normal file
190
pm_app/src/services/userProfileService.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import apiService from './apiService';
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
custom_user_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
middle_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
full_name: string;
|
||||||
|
role_profile_name?: string;
|
||||||
|
username?: string;
|
||||||
|
language?: string;
|
||||||
|
time_zone?: string;
|
||||||
|
user_image?: string;
|
||||||
|
enabled: number;
|
||||||
|
roles: Array<{
|
||||||
|
role: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserProfileData {
|
||||||
|
first_name?: string;
|
||||||
|
middle_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
role_profile_name?: string;
|
||||||
|
new_password?: string;
|
||||||
|
custom_user_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current logged-in user's profile
|
||||||
|
*/
|
||||||
|
export const fetchCurrentUserProfile = async (): Promise<UserProfile> => {
|
||||||
|
try {
|
||||||
|
// First get the current user's email
|
||||||
|
const sessionResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionData = await sessionResponse.json();
|
||||||
|
const userEmail = sessionData.message;
|
||||||
|
|
||||||
|
if (!userEmail) {
|
||||||
|
throw new Error('No user logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use frappe.client.get which returns all fields including role_profile_name
|
||||||
|
const response = await fetch('/api/method/frappe.client.get', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
doctype: 'User',
|
||||||
|
name: userEmail,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Debug: Log the response
|
||||||
|
console.log('frappe.client.get response:', result);
|
||||||
|
console.log('role_profile_name:', result.message?.role_profile_name);
|
||||||
|
|
||||||
|
if (result.message) {
|
||||||
|
const userData = result.message as UserProfile;
|
||||||
|
|
||||||
|
// Roles are included in the response as a child table
|
||||||
|
if (!userData.roles) {
|
||||||
|
userData.roles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to fetch user profile');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user profile
|
||||||
|
*/
|
||||||
|
export const updateUserProfile = async (
|
||||||
|
userEmail: string,
|
||||||
|
data: UpdateUserProfileData
|
||||||
|
): Promise<UserProfile> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/resource/User/${encodeURIComponent(userEmail)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.exc || result.message || 'Failed to update profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data as UserProfile;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user profile:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change user password using Frappe's update_password method
|
||||||
|
*/
|
||||||
|
export const changeUserPassword = async (
|
||||||
|
newPassword: string,
|
||||||
|
oldPassword?: string
|
||||||
|
): Promise<{ message: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/method/frappe.core.doctype.user.user.update_password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
new_password: newPassword,
|
||||||
|
old_password: oldPassword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Parse error message
|
||||||
|
let errorMsg = 'Failed to change password';
|
||||||
|
if (result.exc) {
|
||||||
|
// Try to extract meaningful error from exception
|
||||||
|
if (result.exc.includes('Incorrect Old Password')) {
|
||||||
|
errorMsg = 'Incorrect old password';
|
||||||
|
} else if (result.exc.includes('Password cannot be same')) {
|
||||||
|
errorMsg = 'New password cannot be the same as old password';
|
||||||
|
} else if (result._server_messages) {
|
||||||
|
try {
|
||||||
|
const messages = JSON.parse(result._server_messages);
|
||||||
|
errorMsg = JSON.parse(messages[0]).message || errorMsg;
|
||||||
|
} catch (e) {
|
||||||
|
// Use default error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Password changed successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing password:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch role profiles for dropdown
|
||||||
|
*/
|
||||||
|
export const fetchRoleProfiles = async (): Promise<Array<{ name: string }>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.apiCall<any>(
|
||||||
|
`/api/resource/Role Profile?fields=["name"]&limit_page_length=100`
|
||||||
|
);
|
||||||
|
|
||||||
|
return response?.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching role profiles:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetchCurrentUserProfile,
|
||||||
|
updateUserProfile,
|
||||||
|
changeUserPassword,
|
||||||
|
fetchRoleProfiles,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user