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
This commit is contained in:
parent
cbffc877fc
commit
d0aa68b37c
@ -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 = () => {
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/projects" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ProjectModulePage /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/projects/list" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ProjectList /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/projects/list/:projectName" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ProjectDetail /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/projects/overview" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ProjectManagementOverviewReport /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/projects/tasks" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ComingSoon title="Project tasks" /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/projects/timesheets" element={
|
||||
<ProtectedRoute>
|
||||
<LayoutWithSidebar><ComingSoon title="Project timesheets" /></LayoutWithSidebar>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
|
||||
131
asm_app/src/components/ProjectSalesAndCustomerPanel.tsx
Normal file
131
asm_app/src/components/ProjectSalesAndCustomerPanel.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
|
||||
{label}
|
||||
</div>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full text-left px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700/50 text-sm text-gray-900 dark:text-white border border-transparent hover:border-blue-300 dark:hover:border-blue-600 transition-colors"
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700/40 text-sm text-gray-400 dark:text-gray-500 min-h-[40px] flex items-center">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ProjectSalesAndCustomerPanel: React.FC<ProjectSalesAndCustomerPanelProps> = ({
|
||||
projectName,
|
||||
isNew,
|
||||
customer,
|
||||
customerName,
|
||||
salesOrder,
|
||||
prefillCustomer,
|
||||
prefillSalesOrder,
|
||||
}) => {
|
||||
const [customerOpen, setCustomerOpen] = useState(true);
|
||||
const [soCount, setSoCount] = useState<number | null>(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 (
|
||||
<div className="space-y-4 mb-5">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canViewFilteredList}
|
||||
onClick={() =>
|
||||
canViewFilteredList &&
|
||||
openDesk(
|
||||
`/app/sales-order/view/list?filters=${encodeURIComponent(JSON.stringify([['project', '=', projectName]]))}`
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700/80 disabled:opacity-45 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
<FaExternalLinkAlt className="text-gray-500 dark:text-gray-400 shrink-0" size={14} />
|
||||
View sales orders{countLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/50 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomerOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left bg-teal-50 dark:bg-teal-950/30 border-b border-teal-100 dark:border-teal-900"
|
||||
>
|
||||
<span className="text-sm font-semibold text-teal-900 dark:text-teal-100">Customer details</span>
|
||||
{customerOpen ? <FaChevronUp className="text-teal-700" /> : <FaChevronDown className="text-teal-700" />}
|
||||
</button>
|
||||
{customerOpen && (
|
||||
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<ReadonlyLinkRow
|
||||
label="Customer"
|
||||
value={custDisplay}
|
||||
onOpen={() => cust && openDesk(`/app/Form/Customer/${encodeURIComponent(cust)}`)}
|
||||
/>
|
||||
<ReadonlyLinkRow
|
||||
label="Sales order"
|
||||
value={so}
|
||||
onOpen={() => so && openDesk(`/app/Form/Sales%20Order/${encodeURIComponent(so)}`)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSalesAndCustomerPanel;
|
||||
@ -17,7 +17,8 @@ import {
|
||||
FileText,
|
||||
HelpCircle,
|
||||
UserCircle,
|
||||
Trash2
|
||||
Trash2,
|
||||
FolderOpen,
|
||||
|
||||
} from 'lucide-react';
|
||||
|
||||
@ -216,8 +217,8 @@ const Sidebar: React.FC<SidebarProps> = ({ 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<SidebarProps> = ({ userEmail }) => {
|
||||
path: '/inventory',
|
||||
visible: getVisibility('inventory')
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
title: t('sidebar.projects'),
|
||||
icon: <FolderOpen size={20} />,
|
||||
path: '/projects',
|
||||
visible: getVisibility('projects')
|
||||
},
|
||||
{
|
||||
id: 'work-orders',
|
||||
title: t('common.workOrders'),
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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": "أصول سيرا",
|
||||
|
||||
@ -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",
|
||||
|
||||
357
asm_app/src/pages/ProjectDetail.tsx
Normal file
357
asm_app/src/pages/ProjectDetail.tsx
Normal file
@ -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<FormState>(() => 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<string, any> => {
|
||||
const o: Record<string, any> = {
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-[40vh] text-gray-500 dark:text-gray-400">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
|
||||
<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/list')}
|
||||
className="p-2 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? t('projects.newProject', 'New project') : form.project_name || projectName}
|
||||
</h1>
|
||||
{!isNew && <p className="text-xs text-gray-500 font-mono">{projectName}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!isNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/projects/overview?project=${encodeURIComponent(projectName)}`)}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-teal-300 dark:border-teal-700 text-teal-800 dark:text-teal-200 text-sm font-medium hover:bg-teal-50 dark:hover:bg-teal-900/20"
|
||||
>
|
||||
<FaChartPie size={14} /> {t('projects.overviewReport', 'Project overview')}
|
||||
</button>
|
||||
)}
|
||||
{!isNew && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white text-sm font-semibold"
|
||||
>
|
||||
<FaEdit size={14} /> {t('listPages.edit', 'Edit')}
|
||||
</button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold disabled:opacity-50"
|
||||
>
|
||||
<FaSave size={14} /> {saving ? '…' : t('common.save', 'Save')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectSalesAndCustomerPanel
|
||||
projectName={isNew ? undefined : projectName}
|
||||
isNew={isNew}
|
||||
customer={form.customer}
|
||||
customerName={form.customer_name}
|
||||
salesOrder={form.sales_order}
|
||||
prefillCustomer={prefillCustomer}
|
||||
prefillSalesOrder={prefillSalesOrder}
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5 space-y-4 shadow-sm">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">
|
||||
{t('projects.projectName', 'Project name')} *
|
||||
</label>
|
||||
<input
|
||||
name="project_name"
|
||||
value={form.project_name}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Status</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={e => setField('status', 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"
|
||||
>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Priority</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={e => setField('priority', 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"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="Low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<LinkField
|
||||
label={t('projects.customer', 'Customer')}
|
||||
doctype="Customer"
|
||||
value={form.customer}
|
||||
onChange={v => setField('customer', v)}
|
||||
disabled={disabled}
|
||||
compact
|
||||
/>
|
||||
<LinkField
|
||||
label={t('projects.company', 'Company')}
|
||||
doctype="Company"
|
||||
value={form.company}
|
||||
onChange={v => setField('company', v)}
|
||||
disabled={disabled}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Start</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.expected_start_date}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">End</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.expected_end_date}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">% Complete</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.percent_complete}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkField
|
||||
label={`${t('projects.salesOrder', 'Sales Order')} (${t('projects.optional', 'optional')})`}
|
||||
doctype="Sales Order"
|
||||
value={form.sales_order}
|
||||
onChange={v => setField('sales_order', v)}
|
||||
disabled={disabled}
|
||||
compact
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Notes</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={e => setField('notes', e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetail;
|
||||
447
asm_app/src/pages/ProjectList.tsx
Normal file
447
asm_app/src/pages/ProjectList.tsx
Normal file
@ -0,0 +1,447 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import {
|
||||
FaFolderOpen,
|
||||
FaPlus,
|
||||
FaSync,
|
||||
FaEye,
|
||||
FaEdit,
|
||||
FaCopy,
|
||||
FaFileExport,
|
||||
FaCheckSquare,
|
||||
FaSquare,
|
||||
FaFilter,
|
||||
FaChevronDown,
|
||||
FaChevronUp,
|
||||
FaSearch,
|
||||
} from 'react-icons/fa';
|
||||
import ListPagination from '../components/ListPagination';
|
||||
import DynamicExportModal from '../components/DynamicExportModal';
|
||||
import projectService, { type Project } from '../services/projectService';
|
||||
import { buildDateRangeFilters } from '../utils/listFilterUtils';
|
||||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
const ProjectList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
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 [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') || 'modified desc');
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [duplicating, setDuplicating] = useState<string | null>(null);
|
||||
|
||||
const apiFilters = useMemo(() => {
|
||||
const f: Record<string, any> = {};
|
||||
if (statusFilter) f.status = statusFilter;
|
||||
if (priorityFilter) f.priority = priorityFilter;
|
||||
if (searchQuery) f.project_name = ['like', `%${searchQuery}%`];
|
||||
Object.assign(f, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||
return f;
|
||||
}, [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 fetchAllForExport = useCallback(
|
||||
() => fetchAllRowsForExport({ doctype: 'Project', filters: apiFilters, order_by: orderBy }),
|
||||
[apiFilters, orderBy]
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [res, cnt] = await Promise.all([
|
||||
projectService.getProjects({
|
||||
filters: apiFilters,
|
||||
limit_start: (currentPage - 1) * pageSize,
|
||||
limit_page_length: pageSize,
|
||||
order_by: orderBy,
|
||||
}),
|
||||
projectService.getProjectCount(apiFilters),
|
||||
]);
|
||||
setProjects(res.data);
|
||||
setTotalCount(cnt);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Failed to load projects');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiFilters, currentPage, orderBy]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
const toggleRow = (name: string) => {
|
||||
setSelectedRows(prev => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(name)) n.delete(name);
|
||||
else n.add(name);
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const allOnPageSelected = projects.length > 0 && projects.every(p => selectedRows.has(p.name));
|
||||
const someSelected = selectedRows.size > 0;
|
||||
|
||||
const toggleSelectAllPage = () => {
|
||||
if (allOnPageSelected) {
|
||||
setSelectedRows(prev => {
|
||||
const n = new Set(prev);
|
||||
projects.forEach(p => n.delete(p.name));
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
setSelectedRows(prev => {
|
||||
const n = new Set(prev);
|
||||
projects.forEach(p => n.add(p.name));
|
||||
return n;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (e: React.MouseEvent, name: string) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
setDuplicating(name);
|
||||
const newName = await projectService.copyProject(name);
|
||||
toast.success(`Duplicated: ${newName}`);
|
||||
await load();
|
||||
navigate(`/projects/list/${encodeURIComponent(newName)}`);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Duplicate failed');
|
||||
} finally {
|
||||
setDuplicating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
setSearchParams(prev => {
|
||||
const n = new URLSearchParams(prev);
|
||||
statusFilter ? n.set('status', statusFilter) : n.delete('status');
|
||||
priorityFilter ? n.set('priority', priorityFilter) : n.delete('priority');
|
||||
searchQuery ? n.set('q', searchQuery) : n.delete('q');
|
||||
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 !== 'modified desc' ? n.set('sort_by', sortBy) : n.delete('sort_by');
|
||||
n.set('page', '1');
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatusFilter('');
|
||||
setPriorityFilter('');
|
||||
setSearchQuery('');
|
||||
setDateFilterBy('');
|
||||
setDateStart('');
|
||||
setDateEnd('');
|
||||
setSortBy('modified desc');
|
||||
setSearchParams({ page: '1' });
|
||||
};
|
||||
|
||||
const hasActiveFilters = !!(statusFilter || priorityFilter || searchQuery || (dateFilterBy && (dateStart || dateEnd)));
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ToastContainer position="top-right" autoClose={3000} />
|
||||
|
||||
<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">
|
||||
<div className="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center shadow-md">
|
||||
<FaFolderOpen className="text-white text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{t('projects.listTitle', 'Projects')}</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{totalCount} {t('projects.total', 'total')}
|
||||
{someSelected && (
|
||||
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||||
• {selectedRows.size} {t('listPages.selected', 'selected')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
disabled={totalCount === 0}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold shadow"
|
||||
>
|
||||
<FaFileExport size={14} /> {t('listPages.export', 'Export')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects/list/new')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold shadow"
|
||||
>
|
||||
<FaPlus size={14} /> {t('projects.newProject', 'New project')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm mb-6 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-2.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="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={14} />
|
||||
<span className="text-white font-semibold text-sm">{t('listPages.filters', 'Filters')}</span>
|
||||
{hasActiveFilters && (
|
||||
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||
{[searchQuery, statusFilter, priorityFilter].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasActiveFilters && (
|
||||
<button type="button" onClick={clearFilters} className="text-xs text-white/90 underline">
|
||||
{t('listPages.clearFilters', 'Clear all')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => load()} 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 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase 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
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && applyFilters()}
|
||||
className="w-full pl-8 pr-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
placeholder="Project name…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">All</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-semibold text-gray-500 uppercase mb-1">Priority</label>
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={e => setPriorityFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">All</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-semibold text-gray-500 uppercase mb-1">Sort</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="modified desc">Modified (newest)</option>
|
||||
<option value="modified asc">Modified (oldest)</option>
|
||||
<option value="creation desc">Created (newest)</option>
|
||||
<option value="name asc">ID (A–Z)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-4 flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={applyFilters} className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700">
|
||||
{t('listPages.applyFilters', 'Apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-blue-600 text-left">
|
||||
<th className="px-3 py-3 w-12">
|
||||
<button type="button" onClick={toggleSelectAllPage} className="text-white/90 hover:text-white p-1">
|
||||
{allOnPageSelected ? <FaCheckSquare size={16} /> : <FaSquare size={16} />}
|
||||
</button>
|
||||
</th>
|
||||
{['Project', 'Status', 'Priority', 'Customer', 'Progress', 'Dates', 'Actions'].map(h => (
|
||||
<th key={h} className="px-4 py-3 text-[10px] font-semibold text-white/90 uppercase tracking-wider">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center py-12 text-gray-400">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : projects.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center py-12 text-gray-400">
|
||||
No projects found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
projects.map(p => (
|
||||
<tr
|
||||
key={p.name}
|
||||
className="hover:bg-blue-50/50 dark:hover:bg-gray-700/30 cursor-pointer"
|
||||
onClick={() => navigate(`/projects/list/${encodeURIComponent(p.name)}`)}
|
||||
>
|
||||
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<button type="button" onClick={() => toggleRow(p.name)} className="text-gray-500 hover:text-blue-600 p-1">
|
||||
{selectedRows.has(p.name) ? <FaCheckSquare className="text-blue-600" /> : <FaSquare />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{p.project_name || p.name}</span>
|
||||
<div className="text-xs text-gray-400">{p.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
|
||||
{p.status || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
|
||||
{p.priority || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{p.customer_name || p.customer || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{p.percent_complete ?? 0}%</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{p.expected_start_date || '—'} → {p.expected_end_date || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
title="View"
|
||||
onClick={() => navigate(`/projects/list/${encodeURIComponent(p.name)}`)}
|
||||
className="p-1.5 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/30 text-blue-600"
|
||||
>
|
||||
<FaEye size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Edit"
|
||||
onClick={() => navigate(`/projects/list/${encodeURIComponent(p.name)}?edit=1`)}
|
||||
className="p-1.5 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 text-green-600"
|
||||
>
|
||||
<FaEdit size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Duplicate"
|
||||
disabled={duplicating === p.name}
|
||||
onClick={e => handleDuplicate(e, p.name)}
|
||||
className="p-1.5 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/30 text-purple-600 disabled:opacity-50"
|
||||
>
|
||||
<FaCopy size={14} />
|
||||
</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>
|
||||
|
||||
<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"
|
||||
defaultColumns={['name', 'project_name', 'status', 'priority', 'customer', 'customer_name', 'percent_complete', 'company', 'modified']}
|
||||
hiddenColumns={['docstatus', 'idx', 'naming_series']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectList;
|
||||
412
asm_app/src/pages/ProjectManagementOverviewReport.tsx
Normal file
412
asm_app/src/pages/ProjectManagementOverviewReport.tsx
Normal file
@ -0,0 +1,412 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaArrowLeft, FaFolderOpen, FaSync, FaExclamationTriangle, FaTasks } from 'react-icons/fa';
|
||||
import projectService, { type Project, type TaskRow } from '../services/projectService';
|
||||
|
||||
function formatDate(d?: string) {
|
||||
if (!d) return '—';
|
||||
try {
|
||||
return new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
function daysBetween(a?: string, b?: string): number | null {
|
||||
if (!a || !b) return null;
|
||||
const t0 = new Date(a).getTime();
|
||||
const t1 = new Date(b).getTime();
|
||||
if (Number.isNaN(t0) || Number.isNaN(t1)) return null;
|
||||
return Math.max(0, Math.round((t1 - t0) / (24 * 3600 * 1000)));
|
||||
}
|
||||
|
||||
function MiniBar({ label, pct, colorClass }: { label: string; pct: number; colorClass: string }) {
|
||||
const p = Math.min(100, Math.max(0, pct));
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span>{label}</span>
|
||||
<span>{p.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${colorClass}`} style={{ width: `${p}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDonut({
|
||||
pct,
|
||||
completed,
|
||||
inProgress,
|
||||
open,
|
||||
}: {
|
||||
pct: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
open: number;
|
||||
}) {
|
||||
const total = completed + inProgress + open || 1;
|
||||
const c = (completed / total) * 100;
|
||||
const ip = (inProgress / total) * 100;
|
||||
const o = (open / total) * 100;
|
||||
const grad = `conic-gradient(rgb(37 99 235) 0% ${c}%, rgb(234 179 8) ${c}% ${c + ip}%, rgb(156 163 175) ${c + ip}% 100%)`;
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className="w-36 h-36 rounded-full flex items-center justify-center relative"
|
||||
style={{ background: grad }}
|
||||
>
|
||||
<div className="w-24 h-24 rounded-full bg-white dark:bg-gray-800 flex flex-col items-center justify-center text-center shadow-inner">
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{pct}%</div>
|
||||
<div className="text-[10px] text-gray-500 px-1">progress</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-600 mr-1" /> Done: {completed}
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-yellow-500 mr-1" /> Active: {inProgress}
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-gray-400 mr-1" /> Open: {open}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ProjectManagementOverviewReport: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const projectKey = searchParams.get('project') || '';
|
||||
|
||||
const [projectList, setProjectList] = useState<Pick<Project, 'name' | 'project_name'>[]>([]);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [tasks, setTasks] = useState<TaskRow[]>([]);
|
||||
const [issues, setIssues] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await projectService.getProjects({ limit_page_length: 500, order_by: 'modified desc' });
|
||||
if (!cancelled) setProjectList(data.map(p => ({ name: p.name, project_name: p.project_name })));
|
||||
} catch {
|
||||
if (!cancelled) setProjectList([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadProjectBundle = useCallback(async (name: string) => {
|
||||
if (!name) {
|
||||
setProject(null);
|
||||
setTasks([]);
|
||||
setIssues([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const [p, tk, iss] = await Promise.all([
|
||||
projectService.getProject(name),
|
||||
projectService.getTasksForProject(name, { limit: 300 }),
|
||||
projectService.getIssuesForProject(name, 100),
|
||||
]);
|
||||
setProject(p);
|
||||
setTasks(tk);
|
||||
setIssues(iss);
|
||||
} catch {
|
||||
setProject(null);
|
||||
setTasks([]);
|
||||
setIssues([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjectBundle(projectKey);
|
||||
}, [projectKey, loadProjectBundle]);
|
||||
|
||||
const onSelectProject = (name: string) => {
|
||||
setSearchParams(name ? { project: name } : {});
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const elapsedPct = useMemo(() => {
|
||||
if (!project?.expected_start_date || !project?.expected_end_date) return 0;
|
||||
const total = daysBetween(project.expected_start_date, project.expected_end_date) || 1;
|
||||
const done = daysBetween(project.expected_start_date, today) ?? 0;
|
||||
return Math.min(100, Math.round((done / total) * 100));
|
||||
}, [project, today]);
|
||||
|
||||
const budgetEstimate = project?.estimated_costing ?? project?.total_sales_amount ?? 0;
|
||||
const salesOrBill = project?.total_sales_amount ?? project?.total_billed_amount ?? 0;
|
||||
const spendPct =
|
||||
budgetEstimate > 0 ? Math.min(100, Math.round(((project?.total_costing_amount || 0) / budgetEstimate) * 100)) : 0;
|
||||
const salesPct = budgetEstimate > 0 ? Math.min(100, Math.round((salesOrBill / budgetEstimate) * 100)) : 0;
|
||||
|
||||
const taskStats = useMemo(() => {
|
||||
let completed = 0;
|
||||
let inProgress = 0;
|
||||
let open = 0;
|
||||
for (const tk of tasks) {
|
||||
const s = (tk.status || '').toLowerCase();
|
||||
if (s.includes('complet') || s.includes('closed') || s.includes('cancel')) completed += 1;
|
||||
else if (s.includes('progress') || s.includes('working')) inProgress += 1;
|
||||
else open += 1;
|
||||
}
|
||||
const docPct = project?.percent_complete ?? 0;
|
||||
const mixPct = tasks.length ? Math.round((completed / tasks.length) * 100) : docPct;
|
||||
return { completed, inProgress, open, mixPct };
|
||||
}, [tasks, project?.percent_complete]);
|
||||
|
||||
const issueByPri = useMemo(() => {
|
||||
const m: Record<string, number> = { High: 0, Medium: 0, Low: 0 };
|
||||
for (const i of issues) {
|
||||
const p = i.priority || 'Low';
|
||||
if (p in m) m[p] += 1;
|
||||
else m.Low += 1;
|
||||
}
|
||||
return m;
|
||||
}, [issues]);
|
||||
|
||||
const openIssues = issues.filter(i => {
|
||||
const s = (i.status || '').toLowerCase();
|
||||
return !s.includes('closed') && !s.includes('resolved');
|
||||
});
|
||||
|
||||
const lastWeekTasks = useMemo(() => {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - 7);
|
||||
return tasks.filter(tk => {
|
||||
if (!tk.modified) return false;
|
||||
return new Date(tk.modified) >= cutoff;
|
||||
});
|
||||
}, [tasks]);
|
||||
|
||||
const upcomingTasks = useMemo(() => {
|
||||
return tasks
|
||||
.filter(tk => tk.exp_start_date && new Date(tk.exp_start_date) >= new Date(today))
|
||||
.sort((a, b) => (a.exp_start_date || '').localeCompare(b.exp_start_date || ''))
|
||||
.slice(0, 8);
|
||||
}, [tasks, today]);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
</button>
|
||||
<div className="w-12 h-12 rounded-xl bg-teal-600 flex items-center justify-center">
|
||||
<FaFolderOpen className="text-white text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{t('projects.overviewReport', 'Project overview')}
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projects.overviewReportSub', 'Budget, tasks, issues & weekly status')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={projectKey}
|
||||
onChange={e => onSelectProject(e.target.value)}
|
||||
className="min-w-[220px] px-3 py-2 text-sm border rounded-lg dark:bg-gray-800 dark:border-gray-600"
|
||||
>
|
||||
<option value="">{t('projects.pickProject', 'Select a project…')}</option>
|
||||
{projectList.map(p => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.project_name || p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadProjectBundle(projectKey)}
|
||||
className="p-2 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
title="Refresh"
|
||||
>
|
||||
<FaSync className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!projectKey && (
|
||||
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 p-10 text-center text-gray-500">
|
||||
{t('projects.selectToView', 'Choose a project to view the dashboard.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectKey && loading && (
|
||||
<div className="text-center py-16 text-gray-500">{t('common.loading', 'Loading…')}</div>
|
||||
)}
|
||||
|
||||
{projectKey && !loading && project && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-4 gap-4">
|
||||
<div className="xl:col-span-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-teal-800 dark:text-teal-200 mb-2">{project.project_name}</h2>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
{t('projects.start', 'Start')}: {formatDate(project.expected_start_date)}
|
||||
</span>
|
||||
<span>
|
||||
{t('projects.end', 'End')}: {formatDate(project.expected_end_date)}
|
||||
</span>
|
||||
<span>ID: {project.name}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<MiniBar label={t('projects.timelineElapsed', 'Timeline elapsed (to today)')} pct={elapsedPct} colorClass="bg-teal-500" />
|
||||
<MiniBar
|
||||
label={t('projects.docProgress', 'Documented % complete')}
|
||||
pct={project.percent_complete ?? 0}
|
||||
colorClass="bg-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||
{t('projects.weeklyStatus', 'Weekly project status')}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 shadow-sm">
|
||||
<h4 className="text-xs font-bold text-gray-500 uppercase mb-2">Budget / sales</h4>
|
||||
<MiniBar label={t('projects.relativeCosting', 'Costs vs estimate')} pct={spendPct} colorClass="bg-blue-700" />
|
||||
<MiniBar label={t('projects.relativeBilling', 'Sales vs estimate')} pct={salesPct} colorClass="bg-sky-400" />
|
||||
<p className="text-[11px] text-gray-500 mt-2">
|
||||
Est.: {budgetEstimate ? budgetEstimate.toLocaleString() : '—'} · Sales:{' '}
|
||||
{salesOrBill ? salesOrBill.toLocaleString() : '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 shadow-sm flex flex-col items-center">
|
||||
<h4 className="text-xs font-bold text-gray-500 uppercase mb-2 w-full text-left">Tasks</h4>
|
||||
<TaskDonut
|
||||
pct={taskStats.mixPct}
|
||||
completed={taskStats.completed}
|
||||
inProgress={taskStats.inProgress}
|
||||
open={taskStats.open}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 shadow-sm">
|
||||
<h4 className="text-xs font-bold text-gray-500 uppercase mb-2 flex items-center gap-1">
|
||||
<FaExclamationTriangle className="text-amber-500" /> Issues (priority)
|
||||
</h4>
|
||||
<MiniBar label="High" pct={openIssues.length ? (issueByPri.High / openIssues.length) * 100 : 0} colorClass="bg-red-500" />
|
||||
<MiniBar
|
||||
label="Medium"
|
||||
pct={openIssues.length ? (issueByPri.Medium / openIssues.length) * 100 : 0}
|
||||
colorClass="bg-amber-500"
|
||||
/>
|
||||
<MiniBar label="Low" pct={openIssues.length ? (issueByPri.Low / openIssues.length) * 100 : 0} colorClass="bg-green-500" />
|
||||
<p className="text-[11px] text-gray-500 mt-2">Open issues: {openIssues.length}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 shadow-sm">
|
||||
<h4 className="text-xs font-bold text-gray-500 uppercase mb-2 flex items-center gap-1">
|
||||
<FaTasks /> Summary
|
||||
</h4>
|
||||
<ul className="text-xs text-gray-600 dark:text-gray-400 space-y-2 list-disc pl-4">
|
||||
<li>
|
||||
Tasks: {tasks.length} · Issues: {issues.length}
|
||||
</li>
|
||||
<li>{project.notes ? String(project.notes).slice(0, 200) : '—'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||||
<div className="bg-violet-100 dark:bg-violet-900/40 px-3 py-2 text-xs font-semibold text-violet-900 dark:text-violet-100">
|
||||
Last 7 days — tasks touched
|
||||
</div>
|
||||
<table className="min-w-full text-xs">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{lastWeekTasks.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-4 text-gray-400">None</td>
|
||||
</tr>
|
||||
) : (
|
||||
lastWeekTasks.map((tk, i) => (
|
||||
<tr key={tk.name}>
|
||||
<td className="px-3 py-2">{i + 1}</td>
|
||||
<td className="px-3 py-2">{tk.subject}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{tk.status}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||||
<div className="bg-violet-100 dark:bg-violet-900/40 px-3 py-2 text-xs font-semibold text-violet-900 dark:text-violet-100">
|
||||
Upcoming (by start date)
|
||||
</div>
|
||||
<table className="min-w-full text-xs">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{upcomingTasks.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-4 text-gray-400">None</td>
|
||||
</tr>
|
||||
) : (
|
||||
upcomingTasks.map((tk, i) => (
|
||||
<tr key={tk.name}>
|
||||
<td className="px-3 py-2">{i + 1}</td>
|
||||
<td className="px-3 py-2">{tk.subject}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{formatDate(tk.exp_start_date)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||||
<div className="bg-violet-100 dark:bg-violet-900/40 px-3 py-2 text-xs font-semibold text-violet-900 dark:text-violet-100">
|
||||
Open issues
|
||||
</div>
|
||||
<table className="min-w-full text-xs">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{openIssues.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-4 text-gray-400">None</td>
|
||||
</tr>
|
||||
) : (
|
||||
openIssues.slice(0, 10).map((iss, i) => (
|
||||
<tr key={iss.name}>
|
||||
<td className="px-3 py-2">{i + 1}</td>
|
||||
<td className="px-3 py-2">{iss.subject}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{iss.priority}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectManagementOverviewReport;
|
||||
80
asm_app/src/pages/ProjectModulePage.tsx
Normal file
80
asm_app/src/pages/ProjectModulePage.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaFolderOpen, FaChartPie, FaArrowRight, FaTasks, FaClock } from 'react-icons/fa';
|
||||
|
||||
const ProjectModulePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
title: t('projects.allProjects', 'All projects'),
|
||||
subtitle: t('projects.allProjectsSub', 'Browse, export, duplicate'),
|
||||
icon: <FaFolderOpen className="text-white text-lg" />,
|
||||
color: 'bg-blue-600',
|
||||
onClick: () => navigate('/projects/list'),
|
||||
},
|
||||
{
|
||||
title: t('projects.overviewReport', 'Project overview'),
|
||||
subtitle: t('projects.overviewReportSub', 'Budget, tasks, issues & weekly status'),
|
||||
icon: <FaChartPie className="text-white text-lg" />,
|
||||
color: 'bg-teal-600',
|
||||
onClick: () => navigate('/projects/overview'),
|
||||
},
|
||||
{
|
||||
title: t('projects.tasks', 'Tasks'),
|
||||
subtitle: t('projects.tasksSub', 'Task list (all projects)'),
|
||||
icon: <FaTasks className="text-white text-lg" />,
|
||||
color: 'bg-indigo-600',
|
||||
onClick: () => navigate('/projects/tasks'),
|
||||
},
|
||||
{
|
||||
title: t('projects.timesheets', 'Timesheets'),
|
||||
subtitle: t('projects.timesheetsSub', 'Time entries (all projects)'),
|
||||
icon: <FaClock className="text-white text-lg" />,
|
||||
color: 'bg-amber-600',
|
||||
onClick: () => navigate('/projects/timesheets'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-600 flex items-center justify-center shadow-lg">
|
||||
<FaFolderOpen className="text-white text-2xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('projects.moduleTitle', 'Project management')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('projects.moduleSubtitle', 'Projects, overview dashboard, tasks & timesheets')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{tiles.map(tile => (
|
||||
<button
|
||||
key={tile.title}
|
||||
type="button"
|
||||
onClick={tile.onClick}
|
||||
className="flex items-center gap-4 p-5 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-blue-300 dark:hover:border-blue-600 hover:shadow-md transition-all text-left w-full"
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-xl ${tile.color} flex items-center justify-center shrink-0`}>
|
||||
{tile.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">{tile.title}</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{tile.subtitle}</p>
|
||||
</div>
|
||||
<FaArrowRight className="text-gray-400 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectModulePage;
|
||||
@ -283,6 +283,21 @@ class ApiService {
|
||||
formData.append('usr', credentials.email);
|
||||
formData.append('pwd', credentials.password);
|
||||
|
||||
const csrf =
|
||||
typeof window !== 'undefined' && (window as any).csrf_token
|
||||
? String((window as any).csrf_token).trim()
|
||||
: '';
|
||||
if (csrf && !csrf.includes('{{')) {
|
||||
formData.append('csrf_token', csrf);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (csrf && !csrf.includes('{{')) {
|
||||
headers['X-Frappe-CSRF-Token'] = csrf;
|
||||
}
|
||||
|
||||
const url = `${this.baseURL}${this.endpoints.LOGIN}`;
|
||||
|
||||
try {
|
||||
@ -291,9 +306,7 @@ class ApiService {
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: 'include', // Important: Include cookies
|
||||
signal: controller.signal
|
||||
@ -302,12 +315,23 @@ class ApiService {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ApiResponse = await response.json().catch(() => ({}));
|
||||
// Hide detailed error messages in production
|
||||
const errorMessage = import.meta.env.DEV
|
||||
? (errorData.error || `HTTP error! status: ${response.status}`)
|
||||
: 'Invalid credentials. Please try again.';
|
||||
throw new ApiError(errorMessage, response.status);
|
||||
const errorData: any = await response.json().catch(() => ({}));
|
||||
const frappeMsg =
|
||||
typeof errorData?.message === 'string'
|
||||
? errorData.message
|
||||
: Array.isArray(errorData?.errors) && errorData.errors[0]?.message
|
||||
? errorData.errors[0].message
|
||||
: '';
|
||||
const base =
|
||||
errorData.error ||
|
||||
frappeMsg ||
|
||||
(typeof errorData.exc === 'string' ? errorData.exc.split('\n')[0] : '') ||
|
||||
`HTTP error! status: ${response.status}`;
|
||||
const safeForUser =
|
||||
response.status === 401 || response.status === 403
|
||||
? base || 'Invalid credentials or session expired. Please refresh the page and try again.'
|
||||
: base || 'Login failed. Please try again.';
|
||||
throw new ApiError(safeForUser, response.status);
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
@ -348,13 +372,16 @@ class ApiService {
|
||||
return { message: data } as LoginResponse;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
// Only log detailed errors in development
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[API Service] Login error:', error.message);
|
||||
}
|
||||
const isAbort = error.name === 'AbortError';
|
||||
throw new ApiError(
|
||||
import.meta.env.DEV ? error.message : 'Login failed. Please try again.'
|
||||
isAbort ? 'Request timed out. Please try again.' : error.message || 'Login failed. Please try again.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
|
||||
217
asm_app/src/services/projectService.ts
Normal file
217
asm_app/src/services/projectService.ts
Normal file
@ -0,0 +1,217 @@
|
||||
import { toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||
|
||||
export interface Project {
|
||||
name: string;
|
||||
project_name?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
customer?: string;
|
||||
customer_name?: string;
|
||||
company?: string;
|
||||
percent_complete?: number;
|
||||
expected_start_date?: string;
|
||||
expected_end_date?: string;
|
||||
estimated_costing?: number;
|
||||
total_sales_amount?: number;
|
||||
total_costing_amount?: number;
|
||||
total_billable_amount?: number;
|
||||
total_billed_amount?: number;
|
||||
notes?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface TaskRow {
|
||||
name: string;
|
||||
subject?: string;
|
||||
project?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
exp_start_date?: string;
|
||||
exp_end_date?: string;
|
||||
progress?: number;
|
||||
_assign?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ProjectListParams {
|
||||
filters?: Record<string, any>;
|
||||
limit_start?: number;
|
||||
limit_page_length?: number;
|
||||
order_by?: string;
|
||||
}
|
||||
|
||||
class ProjectService {
|
||||
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;
|
||||
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: { ...headers, ...(opts.headers || {}) }, ...opts });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.message || body.exc || `HTTP ${r.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
async getProjects(params: ProjectListParams = {}): Promise<{ data: Project[] }> {
|
||||
const { filters = {}, limit_start = 0, limit_page_length = 20, order_by = 'modified desc' } = params;
|
||||
const q = new URLSearchParams();
|
||||
q.set(
|
||||
'fields',
|
||||
JSON.stringify([
|
||||
'name',
|
||||
'project_name',
|
||||
'status',
|
||||
'priority',
|
||||
'customer',
|
||||
'customer_name',
|
||||
'company',
|
||||
'percent_complete',
|
||||
'expected_start_date',
|
||||
'expected_end_date',
|
||||
'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));
|
||||
}
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project?${q}`);
|
||||
return { data: 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 getProject(name: string): Promise<Project> {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Project/${encodeURIComponent(name)}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getTasksForProject(projectName: string, opts: { limit?: number; order_by?: string } = {}): Promise<TaskRow[]> {
|
||||
const { limit = 200, order_by = 'modified desc' } = opts;
|
||||
const filters = [['Task', 'project', '=', projectName]];
|
||||
const q = new URLSearchParams();
|
||||
q.set(
|
||||
'fields',
|
||||
JSON.stringify([
|
||||
'name',
|
||||
'subject',
|
||||
'project',
|
||||
'status',
|
||||
'priority',
|
||||
'exp_start_date',
|
||||
'exp_end_date',
|
||||
'progress',
|
||||
'_assign',
|
||||
'modified',
|
||||
])
|
||||
);
|
||||
q.set('filters', JSON.stringify(filters));
|
||||
q.set('limit_page_length', String(limit));
|
||||
q.set('order_by', order_by);
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Task?${q}`);
|
||||
return r.data || [];
|
||||
}
|
||||
|
||||
async getIssuesForProject(projectName: string, limit = 100): Promise<any[]> {
|
||||
const filters = [['Issue', 'project', '=', projectName]];
|
||||
const q = new URLSearchParams();
|
||||
q.set('fields', JSON.stringify(['name', 'subject', 'status', 'priority', 'creation', 'project']));
|
||||
q.set('filters', JSON.stringify(filters));
|
||||
q.set('limit_page_length', String(limit));
|
||||
q.set('order_by', 'modified desc');
|
||||
try {
|
||||
const r = await this.fetchJson(`${this.baseURL}/api/resource/Issue?${q}`);
|
||||
return r.data || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async copyProject(name: string): Promise<string> {
|
||||
const headers = await this.getHeaders();
|
||||
const r = await fetch(`${this.baseURL}/api/method/frappe.client.copy_doc`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: JSON.stringify({ doctype: 'Project', name }),
|
||||
});
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.message || body.exc || 'Duplicate failed');
|
||||
const msg = body.message;
|
||||
if (typeof msg === 'string') return msg;
|
||||
if (msg && typeof msg === 'object' && msg.name) return msg.name;
|
||||
throw new Error('Duplicate did not return new project name');
|
||||
}
|
||||
|
||||
async createProject(doc: Record<string, any>): Promise<Project> {
|
||||
const headers = await this.getHeaders();
|
||||
const r = await fetch(`${this.baseURL}/api/resource/Project`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: JSON.stringify(doc),
|
||||
});
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.message || body.exc || body._server_messages || `HTTP ${r.status}`);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async updateProject(name: string, doc: Record<string, any>): Promise<Project> {
|
||||
const headers = await this.getHeaders();
|
||||
const r = await fetch(`${this.baseURL}/api/resource/Project/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: JSON.stringify(doc),
|
||||
});
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.message || body.exc || body._server_messages || `HTTP ${r.status}`);
|
||||
return body.data;
|
||||
}
|
||||
}
|
||||
|
||||
const projectService = new ProjectService();
|
||||
export default projectService;
|
||||
50
asm_app/src/utils/frappeListExport.ts
Normal file
50
asm_app/src/utils/frappeListExport.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { toFrappeFilterArray } from './listFilterUtils';
|
||||
|
||||
export interface FetchAllRowsParams {
|
||||
doctype: string;
|
||||
filters?: Record<string, any>;
|
||||
order_by?: string;
|
||||
fields?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Page through frappe.client.get_list for CSV/Excel export (respects user permissions).
|
||||
*/
|
||||
export async function fetchAllRowsForExport(params: FetchAllRowsParams): Promise<any[]> {
|
||||
const { doctype, filters = {}, order_by = 'modified desc', fields } = params;
|
||||
const filterArray = toFrappeFilterArray(filters);
|
||||
const pageSize = 500;
|
||||
const all: any[] = [];
|
||||
let limit_start = 0;
|
||||
|
||||
for (;;) {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
doctype,
|
||||
filters: filterArray.length > 0 ? filterArray : {},
|
||||
fields: fields && fields.length > 0 ? fields : ['*'],
|
||||
order_by,
|
||||
limit_start,
|
||||
limit_page_length: pageSize,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/method/frappe.client.get_list', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.exc || `Export fetch failed (${response.status})`);
|
||||
}
|
||||
|
||||
const batch = data.message || [];
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
all.push(...batch);
|
||||
if (batch.length < pageSize) break;
|
||||
limit_start += pageSize;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user