Initial commit of project management

This commit is contained in:
Duradundi Hadimani 2026-06-11 19:56:20 +05:30
commit f3531aa48e
136 changed files with 39469 additions and 0 deletions

12
.gitignore vendored Normal file
View 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.*

1
README.md Normal file
View File

@ -0,0 +1 @@
# Project Management UI

21
license.txt Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

13
pm_app/proxyOptions.ts Normal file
View 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}`;
}
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

1
pm_app/public/vite.svg Normal file
View 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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

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

View 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>
);
};

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

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

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

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

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

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

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

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

View 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}&nbsp;{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>
);
}

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

View 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" },
};

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

View 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 })}`;
}

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

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

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

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

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

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

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

View 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,
};
}

View 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,
};
};

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

17
pm_app/src/main.tsx Normal file
View 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>,
)

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

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

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

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

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

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

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

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

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

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

View 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>
</>
);
}

View 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>
</>
);
}

File diff suppressed because it is too large Load Diff

View 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 AZ</option>
<option value="name desc">Name ZA</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;

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

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

View 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();

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

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

View 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();

View 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,
};

View 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 Tasks `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;

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

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

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

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

View 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);
}

View 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);
}
}

View 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