commit f3531aa48e55b170251e65859d05e707bd936116 Author: Duradundi Hadimani Date: Thu Jun 11 19:56:20 2026 +0530 Initial commit of project management diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a43f41e --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..044e637 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Project Management UI diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..8aa2645 --- /dev/null +++ b/license.txt @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..bc95835 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/pm_app/.gitignore b/pm_app/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/pm_app/.gitignore @@ -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? diff --git a/pm_app/eslint.config.js b/pm_app/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/pm_app/eslint.config.js @@ -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, + }, + }, +]) diff --git a/pm_app/index.html b/pm_app/index.html new file mode 100644 index 0000000..8af0e50 --- /dev/null +++ b/pm_app/index.html @@ -0,0 +1,16 @@ + + + + + + + + + Project Management + + +
+ + + + diff --git a/pm_app/package.json b/pm_app/package.json new file mode 100644 index 0000000..2833a3a --- /dev/null +++ b/pm_app/package.json @@ -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" + } +} diff --git a/pm_app/postcss.config.js b/pm_app/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/pm_app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/pm_app/proxyOptions.ts b/pm_app/proxyOptions.ts new file mode 100644 index 0000000..e1aeca8 --- /dev/null +++ b/pm_app/proxyOptions.ts @@ -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}`; + } + } +}; diff --git a/pm_app/public/seera-logo.png b/pm_app/public/seera-logo.png new file mode 100644 index 0000000..a978072 Binary files /dev/null and b/pm_app/public/seera-logo.png differ diff --git a/pm_app/public/sidebar-background.jpg b/pm_app/public/sidebar-background.jpg new file mode 100644 index 0000000..d21ff19 Binary files /dev/null and b/pm_app/public/sidebar-background.jpg differ diff --git a/pm_app/public/vite.svg b/pm_app/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/pm_app/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pm_app/scripts/inject-image-version.js b/pm_app/scripts/inject-image-version.js new file mode 100644 index 0000000..f0fbcf3 --- /dev/null +++ b/pm_app/scripts/inject-image-version.js @@ -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); +} diff --git a/pm_app/src/App.tsx b/pm_app/src/App.tsx new file mode 100644 index 0000000..2526b4a --- /dev/null +++ b/pm_app/src/App.tsx @@ -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 ( + +
+ +
+
+
{children}
+
+
+
+ ); +}; + +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 ( +
+
+ + + + + Loading… +
+
+ ); + } + + if (status === 'guest') { + return ; + } + + return <>{children}; +}; + +const App: React.FC = () => ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +); + +export default App; diff --git a/pm_app/src/assets/audio/ar_no_selection.mp3 b/pm_app/src/assets/audio/ar_no_selection.mp3 new file mode 100644 index 0000000..710b53c Binary files /dev/null and b/pm_app/src/assets/audio/ar_no_selection.mp3 differ diff --git a/pm_app/src/assets/audio/ar_prompt.mp3 b/pm_app/src/assets/audio/ar_prompt.mp3 new file mode 100644 index 0000000..c1ac560 Binary files /dev/null and b/pm_app/src/assets/audio/ar_prompt.mp3 differ diff --git a/pm_app/src/assets/audio/ar_task_prompt.mp3 b/pm_app/src/assets/audio/ar_task_prompt.mp3 new file mode 100644 index 0000000..5c16ca7 Binary files /dev/null and b/pm_app/src/assets/audio/ar_task_prompt.mp3 differ diff --git a/pm_app/src/assets/audio/en_no_selection_prompt.mp3 b/pm_app/src/assets/audio/en_no_selection_prompt.mp3 new file mode 100644 index 0000000..204b477 Binary files /dev/null and b/pm_app/src/assets/audio/en_no_selection_prompt.mp3 differ diff --git a/pm_app/src/assets/audio/en_status_prompt.mp3 b/pm_app/src/assets/audio/en_status_prompt.mp3 new file mode 100644 index 0000000..8078f6d Binary files /dev/null and b/pm_app/src/assets/audio/en_status_prompt.mp3 differ diff --git a/pm_app/src/assets/audio/en_task_prompt.mp3 b/pm_app/src/assets/audio/en_task_prompt.mp3 new file mode 100644 index 0000000..728be11 Binary files /dev/null and b/pm_app/src/assets/audio/en_task_prompt.mp3 differ diff --git a/pm_app/src/components/ActivityLog.tsx b/pm_app/src/components/ActivityLog.tsx new file mode 100644 index 0000000..e61bcc1 --- /dev/null +++ b/pm_app/src/components/ActivityLog.tsx @@ -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 ( +
+ {/* Timeline dot */} +
+ + {/* Entry content */} +
+ {/* Header */} +
+
+
+ +
+ + {formatUsername(log.owner)} + +
+
+ + + {formatAuditDate(log.creation)} + +
+
+ + {/* Changes */} +
+ {log.changes.length > 0 ? ( + log.changes.map((change, i) => ( +
+ + {formatFieldName(change.field)} + + changed from + + {formatValue(change.oldValue)} + + + + {formatValue(change.newValue)} + +
+ )) + ) : ( +

Document updated

+ )} + + {log.added && log.added.length > 0 && ( +
+ Added: {log.added.length} item(s) +
+ )} + {log.removed && log.removed.length > 0 && ( +
+ Removed: {log.removed.length} item(s) +
+ )} + {log.rowChanged && log.rowChanged.length > 0 && ( +
+ Modified: {log.rowChanged.length} row(s) +
+ )} +
+
+
+ ); +}; + +/** "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 ( +
+
+
+
+
+
+ +
+ + {formatUsername(createdBy)} + +
+
+ + + {formatAuditDate(creationDate)} + +
+
+ + + Created this {displayDoctype} + +
+
+ ); +}; + +// ============== MAIN COMPONENT ============== + +const ActivityLog: React.FC = ({ + 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 ( +
+ {/* Header */} +
+
collapsible && setIsExpanded(!isExpanded)} + > + +

+ {title} +

+ {auditLogs.length > 0 && ( + + {auditLogs.length} + + )} +
+
+ + {collapsible && ( + + )} +
+
+ + {/* Content */} + {isExpanded && ( +
+ {/* Loading */} + {loading && ( +
+ + Loading... +
+ )} + + {/* Empty State */} + {!loading && auditLogs.length === 0 && ( +
+
+ +
+
+
+

+ No changes recorded yet +

+
+
+ + {creationDate && createdBy && ( + + )} +
+ )} + + {/* Timeline */} + {!loading && auditLogs.length > 0 && ( +
+
+ +
+ {visibleLogs.map((log, index) => ( + + ))} +
+ + {/* Show More/Less */} + {auditLogs.length > initialVisible && ( +
+ +
+ )} + + {/* Created entry at bottom */} + {creationDate && createdBy && ( +
+ +
+ )} +
+ )} +
+ )} +
+ ); +}; + +export default ActivityLog; \ No newline at end of file diff --git a/pm_app/src/components/DynamicExportModal.tsx b/pm_app/src/components/DynamicExportModal.tsx new file mode 100644 index 0000000..015febf --- /dev/null +++ b/pm_app/src/components/DynamicExportModal.tsx @@ -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; + + /** + * 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; + + /** Current page data (used for 'all_on_page' and 'selected') */ + pageData: any[]; + + /** Set of selected row names/ids */ + selectedRows?: Set; + 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 = ({ + 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(selectedCount > 0 ? 'selected' : 'all_with_filters'); + const [format, setFormat] = useState('csv'); + const [checkedKeys, setCheckedKeys] = useState>(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(); + 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 ( +
+
+ + {/* ── Header ── */} +
+
+
+ +
+

Export {doctype.replace(/_/g, ' ')}

+

+ {allColumns.length} fields available · {checkedKeys.size} selected +

+
+
+ +
+
+ + {/* ── Body (scrollable) ── */} +
+ + {/* Scope */} +
+

What to export

+
+ {/* Selected rows */} + + {/* Current page */} + + {/* All with filters */} + +
+
+ + {/* Format */} +
+

File format

+
+ } + label="CSV" sub="Universal, works everywhere" /> + } + label="Excel (.xlsx)" sub="Native Excel workbook" /> +
+
+ + {/* Column picker */} +
+
+

+ Columns to export + {fieldsLoading && } +

+
+ + + +
+
+ + {/* Search */} +
+ + 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 && ( + + )} +
+ + {/* Field grid */} + {fieldsLoading ? ( +
+ Loading fields… +
+ ) : ( +
+ {filteredColumns.map(col => { + const checked = checkedKeys.has(col.key); + return ( +
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' + }`} + > + + {checked + ? + : } + + + {col.label} + +
+ ); + })} + {filteredColumns.length === 0 && ( +

+ No fields match "{search}" +

+ )} +
+ )} +

+ {checkedKeys.size} of {allColumns.length} fields selected + {search && ` · showing ${filteredColumns.length} matching`} +

+
+
+ + {/* ── Footer ── */} +
+

+ {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`} +

+
+ + +
+
+
+
+ ); +}; + +// ───────────────────────────────────────────── +// Sub-components +// ───────────────────────────────────────────── + +const BADGE_COLORS: Record = { + 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 = ({ value, current, onChange, disabled, label, sub, badge, badgeColor }) => ( + +); + +interface FormatOptionProps { + value: ExportFormat; + current: ExportFormat; + onChange: (v: ExportFormat) => void; + icon: React.ReactNode; + label: string; + sub: string; +} + +const FormatOption: React.FC = ({ value, current, onChange, icon, label, sub }) => ( + +); + +export default DynamicExportModal; \ No newline at end of file diff --git a/pm_app/src/components/DynamicField.tsx b/pm_app/src/components/DynamicField.tsx new file mode 100644 index 0000000..16d294c --- /dev/null +++ b/pm_app/src/components/DynamicField.tsx @@ -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; + disabled?: boolean; + compact?: boolean; + className?: string; + error?: string; +} + +const DynamicField: React.FC = ({ + 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 ( + + ); + + case 'Select': + const options = parseSelectOptions(fieldConfig.options); + return ( +
+ + + {error &&

{error}

} + {fieldConfig.description && !error && ( +

{fieldConfig.description}

+ )} +
+ ); + + case 'Check': + return ( +
+ 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" + /> + +
+ ); + + case 'Date': + return ( +
+ + handleChange(e.target.value)} + disabled={isDisabled} + className={inputClasses} + /> + {error &&

{error}

} +
+ ); + + case 'Datetime': + return ( +
+ + handleChange(e.target.value.replace('T', ' '))} + disabled={isDisabled} + className={inputClasses} + /> + {error &&

{error}

} +
+ ); + + case 'Int': + return ( +
+ + handleChange(e.target.value ? parseInt(e.target.value) : null)} + disabled={isDisabled} + className={inputClasses} + step="1" + /> + {error &&

{error}

} +
+ ); + + case 'Float': + case 'Currency': + case 'Percent': + return ( +
+ + handleChange(e.target.value ? parseFloat(e.target.value) : null)} + disabled={isDisabled} + className={inputClasses} + step="0.01" + /> + {error &&

{error}

} +
+ ); + + case 'Small Text': + case 'Text': + case 'Long Text': + return ( +
+ +