From d0aa68b37c8defe79c0bcc989a4e23ec23022263 Mon Sep 17 00:00:00 2001 From: Duradundi Hadimani Date: Fri, 27 Mar 2026 12:32:50 +0000 Subject: [PATCH] feat(projects): restore project module, overview, export, and desk links - Add Project list/detail, module home, overview report (tasks/issues) - Customer & sales order panel with ERPNext desk navigation - Sidebar Projects entry; role visibility for end users & technicians - Login: send Frappe CSRF for POST; clearer API errors; API base from origin fallback Made-with: Cursor --- asm_app/src/App.tsx | 40 ++ .../ProjectSalesAndCustomerPanel.tsx | 131 +++++ asm_app/src/components/Sidebar.tsx | 14 +- asm_app/src/config/api.ts | 18 +- asm_app/src/locales/ar/translation.json | 35 +- asm_app/src/locales/en/translation.json | 35 +- asm_app/src/pages/ProjectDetail.tsx | 357 ++++++++++++++ asm_app/src/pages/ProjectList.tsx | 447 ++++++++++++++++++ .../pages/ProjectManagementOverviewReport.tsx | 412 ++++++++++++++++ asm_app/src/pages/ProjectModulePage.tsx | 80 ++++ asm_app/src/services/apiService.ts | 51 +- asm_app/src/services/projectService.ts | 217 +++++++++ asm_app/src/utils/frappeListExport.ts | 50 ++ 13 files changed, 1866 insertions(+), 21 deletions(-) create mode 100644 asm_app/src/components/ProjectSalesAndCustomerPanel.tsx create mode 100644 asm_app/src/pages/ProjectDetail.tsx create mode 100644 asm_app/src/pages/ProjectList.tsx create mode 100644 asm_app/src/pages/ProjectManagementOverviewReport.tsx create mode 100644 asm_app/src/pages/ProjectModulePage.tsx create mode 100644 asm_app/src/services/projectService.ts create mode 100644 asm_app/src/utils/frappeListExport.ts diff --git a/asm_app/src/App.tsx b/asm_app/src/App.tsx index a06eba1..3c010fc 100644 --- a/asm_app/src/App.tsx +++ b/asm_app/src/App.tsx @@ -53,6 +53,10 @@ import InspectionDetail from './pages/InspectionDetail'; import SupportPlanList from './pages/SupportPlanList'; import SupportPlanDetail from './pages/SupportPlanDetail'; import UserProfilePage from './pages/UserProfilePage'; +import ProjectModulePage from './pages/ProjectModulePage'; +import ProjectList from './pages/ProjectList'; +import ProjectDetail from './pages/ProjectDetail'; +import ProjectManagementOverviewReport from './pages/ProjectManagementOverviewReport'; // Layout with Sidebar and Header const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -281,6 +285,42 @@ const App: React.FC = () => { } /> + + + + } /> + + + + + } /> + + + + + } /> + + + + + } /> + + + + + } /> + + + + + } /> + {/* Default redirect */} } /> diff --git a/asm_app/src/components/ProjectSalesAndCustomerPanel.tsx b/asm_app/src/components/ProjectSalesAndCustomerPanel.tsx new file mode 100644 index 0000000..629d9f2 --- /dev/null +++ b/asm_app/src/components/ProjectSalesAndCustomerPanel.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { FaChevronDown, FaChevronUp, FaExternalLinkAlt } from 'react-icons/fa'; + +export interface ProjectSalesAndCustomerPanelProps { + projectName?: string; + isNew: boolean; + customer?: string; + customerName?: string; + salesOrder?: string; + prefillCustomer?: string; + prefillSalesOrder?: string; +} + +function ReadonlyLinkRow(props: { label: string; value: string; onOpen?: () => void }) { + const { label, value, onOpen } = props; + return ( +
+
+ {label} +
+ {value ? ( + + ) : ( +
+ — +
+ )} +
+ ); +} + +const ProjectSalesAndCustomerPanel: React.FC = ({ + projectName, + isNew, + customer, + customerName, + salesOrder, + prefillCustomer, + prefillSalesOrder, +}) => { + const [customerOpen, setCustomerOpen] = useState(true); + const [soCount, setSoCount] = useState(null); + + const cust = (customer || prefillCustomer || '').trim(); + const so = (salesOrder || prefillSalesOrder || '').trim(); + const custDisplay = (customerName || cust).trim(); + + const loadSoCount = useCallback(async () => { + if (!projectName || isNew) { + setSoCount(0); + return; + } + try { + const filters = JSON.stringify([['Sales Order', 'project', '=', projectName]]); + const q = new URLSearchParams(); + q.set('fields', JSON.stringify(['count(name) as count'])); + q.set('filters', filters); + const r = await fetch(`/api/resource/Sales Order?${q}`, { credentials: 'include' }); + const body = await r.json().catch(() => ({})); + const n = body?.data?.[0]?.count; + setSoCount(typeof n === 'number' ? n : 0); + } catch { + setSoCount(0); + } + }, [projectName, isNew]); + + useEffect(() => { + loadSoCount(); + }, [loadSoCount]); + + const canViewFilteredList = Boolean(projectName && !isNew); + const countLabel = isNew ? '' : soCount === null ? ' (…)' : ` (${soCount})`; + + const openDesk = (path: string) => { + window.open(`${window.location.origin}${path}`, '_blank', 'noopener,noreferrer'); + }; + + return ( +
+
+ +
+ +
+ + {customerOpen && ( +
+ cust && openDesk(`/app/Form/Customer/${encodeURIComponent(cust)}`)} + /> + so && openDesk(`/app/Form/Sales%20Order/${encodeURIComponent(so)}`)} + /> +
+ )} +
+
+ ); +}; + +export default ProjectSalesAndCustomerPanel; diff --git a/asm_app/src/components/Sidebar.tsx b/asm_app/src/components/Sidebar.tsx index 31088f3..8d99369 100644 --- a/asm_app/src/components/Sidebar.tsx +++ b/asm_app/src/components/Sidebar.tsx @@ -17,7 +17,8 @@ import { FileText, HelpCircle, UserCircle, - Trash2 + Trash2, + FolderOpen, } from 'lucide-react'; @@ -216,8 +217,8 @@ const Sidebar: React.FC = ({ userEmail }) => { } // Define what each role can see - const endUserLinks = ['work-orders', 'support', 'assets', 'inventory']; - const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory']; + const endUserLinks = ['work-orders', 'support', 'assets', 'inventory', 'projects']; + const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory', 'projects']; // Check visibility based on roles (union of permissions) let canSee = false; @@ -287,6 +288,13 @@ const Sidebar: React.FC = ({ userEmail }) => { path: '/inventory', visible: getVisibility('inventory') }, + { + id: 'projects', + title: t('sidebar.projects'), + icon: , + path: '/projects', + visible: getVisibility('projects') + }, { id: 'work-orders', title: t('common.workOrders'), diff --git a/asm_app/src/config/api.ts b/asm_app/src/config/api.ts index 67354cc..6431bd1 100644 --- a/asm_app/src/config/api.ts +++ b/asm_app/src/config/api.ts @@ -6,11 +6,21 @@ interface ApiConfig { TIMEOUT: number; } +function resolveApiBaseUrl(): string { + if (import.meta.env.DEV) { + return ''; + } + const raw = import.meta.env.VITE_FRAPPE_BASE_URL; + const fromEnv = typeof raw === 'string' ? raw.trim().replace(/\/$/, '') : ''; + if (fromEnv) return fromEnv; + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + return 'http://35.252.41.128'; +} + const API_CONFIG: ApiConfig = { - // Backend URL - Use proxy in development, direct URL in production - BASE_URL: import.meta.env.DEV - ? '' // Use relative URLs in development (goes through Vite proxy) - : import.meta.env.VITE_FRAPPE_BASE_URL || 'https://kfsh-dammam-asm.seeraarabia.com', + BASE_URL: resolveApiBaseUrl(), // API Endpoints ENDPOINTS: { diff --git a/asm_app/src/locales/ar/translation.json b/asm_app/src/locales/ar/translation.json index 283a9a3..455b8af 100644 --- a/asm_app/src/locales/ar/translation.json +++ b/asm_app/src/locales/ar/translation.json @@ -64,7 +64,40 @@ "sla": "اتفاقية مستوى الخدمة", "support": "الدعم", "inspection": "التفتيش", - "userProfile": "الملف الشخصي" + "userProfile": "الملف الشخصي", + "projects": "المشاريع" + }, + "projects": { + "moduleTitle": "إدارة المشاريع", + "moduleSubtitle": "المشاريع، لوحة النظرة العامة، المهام وجداول الوقت", + "allProjects": "جميع المشاريع", + "allProjectsSub": "تصفح، تصدير، نسخ", + "overviewReport": "نظرة عامة على المشروع", + "overviewReportSub": "الميزانية، المهام، البلاغات والحالة الأسبوعية", + "tasks": "المهام", + "tasksSub": "قائمة المهام (جميع المشاريع)", + "timesheets": "جداول الوقت", + "timesheetsSub": "سجلات الوقت (جميع المشاريع)", + "listTitle": "المشاريع", + "total": "الإجمالي", + "newProject": "مشروع جديد", + "projectName": "اسم المشروع", + "projectNameRequired": "اسم المشروع مطلوب", + "created": "تم إنشاء المشروع", + "updated": "تم تحديث المشروع", + "customer": "العميل", + "company": "الشركة", + "salesOrder": "أمر بيع", + "optional": "اختياري", + "pickProject": "اختر مشروعًا…", + "selectToView": "اختر مشروعًا لعرض لوحة المعلومات.", + "start": "البداية", + "end": "النهاية", + "timelineElapsed": "الجدول الزمني المنقضي (حتى اليوم)", + "docProgress": "نسبة الإتمام في المستند", + "weeklyStatus": "الحالة الأسبوعية للمشروع", + "relativeCosting": "التكاليف مقابل التقدير", + "relativeBilling": "المبيعات مقابل التقدير" }, "login": { "title": "أصول سيرا", diff --git a/asm_app/src/locales/en/translation.json b/asm_app/src/locales/en/translation.json index c910e51..12a4837 100644 --- a/asm_app/src/locales/en/translation.json +++ b/asm_app/src/locales/en/translation.json @@ -65,7 +65,40 @@ "support": "Support", "inspection": "Inspection", "deleteRequests": "Delete Requests", - "userProfile": "User Profile" + "userProfile": "User Profile", + "projects": "Projects" + }, + "projects": { + "moduleTitle": "Project management", + "moduleSubtitle": "Projects, overview dashboard, tasks & timesheets", + "allProjects": "All projects", + "allProjectsSub": "Browse, export, duplicate", + "overviewReport": "Project overview", + "overviewReportSub": "Budget, tasks, issues & weekly status", + "tasks": "Tasks", + "tasksSub": "Task list (all projects)", + "timesheets": "Timesheets", + "timesheetsSub": "Time entries (all projects)", + "listTitle": "Projects", + "total": "total", + "newProject": "New project", + "projectName": "Project name", + "projectNameRequired": "Project name is required", + "created": "Project created", + "updated": "Project updated", + "customer": "Customer", + "company": "Company", + "salesOrder": "Sales Order", + "optional": "optional", + "pickProject": "Select a project…", + "selectToView": "Choose a project to view the dashboard.", + "start": "Start", + "end": "End", + "timelineElapsed": "Timeline elapsed (to today)", + "docProgress": "Documented % complete", + "weeklyStatus": "Weekly project status", + "relativeCosting": "Costs vs estimate", + "relativeBilling": "Sales vs estimate" }, "login": { "title": "SEERA-ASM", diff --git a/asm_app/src/pages/ProjectDetail.tsx b/asm_app/src/pages/ProjectDetail.tsx new file mode 100644 index 0000000..830a61a --- /dev/null +++ b/asm_app/src/pages/ProjectDetail.tsx @@ -0,0 +1,357 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { FaArrowLeft, FaSave, FaEdit, FaChartPie } from 'react-icons/fa'; +import { toast, ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import projectService, { type Project } from '../services/projectService'; +import LinkField from '../components/LinkField'; +import ProjectSalesAndCustomerPanel from '../components/ProjectSalesAndCustomerPanel'; + +type FormState = { + project_name: string; + status: string; + priority: string; + customer: string; + customer_name: string; + company: string; + expected_start_date: string; + expected_end_date: string; + percent_complete: number | ''; + notes: string; + sales_order: string; +}; + +const emptyForm = (): FormState => ({ + project_name: '', + status: 'Open', + priority: 'Medium', + customer: '', + customer_name: '', + company: '', + expected_start_date: '', + expected_end_date: '', + percent_complete: '', + notes: '', + sales_order: '', +}); + +const ProjectDetail: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const { projectName: rawName } = useParams<{ projectName: string }>(); + + const projectName = useMemo(() => { + if (rawName === 'new') return 'new'; + const prefix = '/projects/list/'; + let path = window.location.pathname; + if (path.startsWith('/asm_app')) path = path.slice('/asm_app'.length) || '/'; + const idx = path.indexOf(prefix); + if (idx !== -1) { + const encoded = path.slice(idx + prefix.length); + try { + return decodeURIComponent(encoded); + } catch { + return encoded; + } + } + return rawName ? decodeURIComponent(rawName) : ''; + }, [rawName, location.pathname]); + + const isNew = projectName === 'new'; + const prefillCustomer = searchParams.get('customer') || ''; + const prefillSalesOrder = searchParams.get('sales_order') || ''; + + const [loading, setLoading] = useState(!isNew); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState(() => emptyForm()); + const [isEditing, setIsEditing] = useState(isNew || searchParams.get('edit') === '1'); + + const load = useCallback(async () => { + if (!projectName || isNew) return; + setLoading(true); + try { + const p: Project = await projectService.getProject(projectName); + setForm({ + project_name: p.project_name || '', + status: p.status || 'Open', + priority: p.priority || '', + customer: p.customer || '', + customer_name: p.customer_name || '', + company: p.company || '', + expected_start_date: (p.expected_start_date || '').split(' ')[0], + expected_end_date: (p.expected_end_date || '').split(' ')[0], + percent_complete: typeof p.percent_complete === 'number' ? p.percent_complete : '', + notes: p.notes || '', + sales_order: (p as any).sales_order || '', + }); + } catch (e: any) { + toast.error(e.message || 'Failed to load project'); + navigate('/projects/list'); + } finally { + setLoading(false); + } + }, [projectName, isNew, navigate]); + + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + if (isNew && (prefillCustomer || prefillSalesOrder)) { + setForm(prev => ({ + ...prev, + customer: prefillCustomer || prev.customer, + sales_order: prefillSalesOrder || prev.sales_order, + })); + } + }, [isNew, prefillCustomer, prefillSalesOrder]); + + useEffect(() => { + if (!isNew && searchParams.get('edit') === '1') setIsEditing(true); + }, [isNew, searchParams]); + + const setField = (name: keyof FormState, value: string | number) => { + setForm(prev => ({ ...prev, [name]: value })); + }; + + const payloadFromForm = (): Record => { + const o: Record = { + project_name: form.project_name.trim(), + status: form.status, + priority: form.priority || undefined, + customer: form.customer || undefined, + company: form.company || undefined, + expected_start_date: form.expected_start_date || undefined, + expected_end_date: form.expected_end_date || undefined, + notes: form.notes || undefined, + }; + if (form.percent_complete !== '' && form.percent_complete != null) { + o.percent_complete = Number(form.percent_complete); + } + if (form.sales_order.trim()) o.sales_order = form.sales_order.trim(); + return o; + }; + + const handleSave = async () => { + if (!form.project_name.trim()) { + toast.error(t('projects.projectNameRequired', 'Project name is required')); + return; + } + setSaving(true); + try { + if (isNew) { + const created = await projectService.createProject(payloadFromForm()); + toast.success(t('projects.created', 'Project created')); + navigate(`/projects/list/${encodeURIComponent(created.name)}`, { replace: true }); + } else { + await projectService.updateProject(projectName, payloadFromForm()); + toast.success(t('projects.updated', 'Project updated')); + setIsEditing(false); + await load(); + } + } catch (e: any) { + toast.error(e.message || 'Save failed'); + } finally { + setSaving(false); + } + }; + + const disabled = !isEditing; + + if (loading && !isNew) { + return ( +
+ Loading… +
+ ); + } + + return ( +
+ + +
+
+ +
+

+ {isNew ? t('projects.newProject', 'New project') : form.project_name || projectName} +

+ {!isNew &&

{projectName}

} +
+
+
+ {!isNew && ( + + )} + {!isNew && !isEditing && ( + + )} + {isEditing && ( + + )} +
+
+ + + +
+
+
+ + setField('project_name', e.target.value)} + disabled={disabled} + className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 disabled:opacity-60" + /> +
+
+
+ + +
+
+ + +
+
+
+ +
+ setField('customer', v)} + disabled={disabled} + compact + /> + setField('company', v)} + disabled={disabled} + compact + /> +
+ +
+
+ + setField('expected_start_date', e.target.value)} + disabled={disabled} + className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 disabled:opacity-60" + /> +
+
+ + setField('expected_end_date', e.target.value)} + disabled={disabled} + className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 disabled:opacity-60" + /> +
+
+ + setField('percent_complete', e.target.value === '' ? '' : Number(e.target.value))} + disabled={disabled} + className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 disabled:opacity-60" + /> +
+
+ + setField('sales_order', v)} + disabled={disabled} + compact + /> + +
+ +