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 SupportPlanList from './pages/SupportPlanList';
|
||||||
import SupportPlanDetail from './pages/SupportPlanDetail';
|
import SupportPlanDetail from './pages/SupportPlanDetail';
|
||||||
import UserProfilePage from './pages/UserProfilePage';
|
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
|
// Layout with Sidebar and Header
|
||||||
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
@ -281,6 +285,42 @@ const App: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</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 */}
|
{/* Default redirect */}
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<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,
|
FileText,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
Trash2
|
Trash2,
|
||||||
|
FolderOpen,
|
||||||
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@ -216,8 +217,8 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Define what each role can see
|
// Define what each role can see
|
||||||
const endUserLinks = ['work-orders', 'support', 'assets', 'inventory'];
|
const endUserLinks = ['work-orders', 'support', 'assets', 'inventory', 'projects'];
|
||||||
const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory'];
|
const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory', 'projects'];
|
||||||
|
|
||||||
// Check visibility based on roles (union of permissions)
|
// Check visibility based on roles (union of permissions)
|
||||||
let canSee = false;
|
let canSee = false;
|
||||||
@ -287,6 +288,13 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
|||||||
path: '/inventory',
|
path: '/inventory',
|
||||||
visible: getVisibility('inventory')
|
visible: getVisibility('inventory')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'projects',
|
||||||
|
title: t('sidebar.projects'),
|
||||||
|
icon: <FolderOpen size={20} />,
|
||||||
|
path: '/projects',
|
||||||
|
visible: getVisibility('projects')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'work-orders',
|
id: 'work-orders',
|
||||||
title: t('common.workOrders'),
|
title: t('common.workOrders'),
|
||||||
|
|||||||
@ -6,11 +6,21 @@ interface ApiConfig {
|
|||||||
TIMEOUT: number;
|
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 = {
|
const API_CONFIG: ApiConfig = {
|
||||||
// Backend URL - Use proxy in development, direct URL in production
|
BASE_URL: resolveApiBaseUrl(),
|
||||||
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',
|
|
||||||
|
|
||||||
// API Endpoints
|
// API Endpoints
|
||||||
ENDPOINTS: {
|
ENDPOINTS: {
|
||||||
|
|||||||
@ -64,7 +64,40 @@
|
|||||||
"sla": "اتفاقية مستوى الخدمة",
|
"sla": "اتفاقية مستوى الخدمة",
|
||||||
"support": "الدعم",
|
"support": "الدعم",
|
||||||
"inspection": "التفتيش",
|
"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": {
|
"login": {
|
||||||
"title": "أصول سيرا",
|
"title": "أصول سيرا",
|
||||||
|
|||||||
@ -65,7 +65,40 @@
|
|||||||
"support": "Support",
|
"support": "Support",
|
||||||
"inspection": "Inspection",
|
"inspection": "Inspection",
|
||||||
"deleteRequests": "Delete Requests",
|
"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": {
|
"login": {
|
||||||
"title": "SEERA-ASM",
|
"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('usr', credentials.email);
|
||||||
formData.append('pwd', credentials.password);
|
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}`;
|
const url = `${this.baseURL}${this.endpoints.LOGIN}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -291,9 +306,7 @@ class ApiService {
|
|||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Accept': 'application/json'
|
|
||||||
},
|
|
||||||
body: formData,
|
body: formData,
|
||||||
credentials: 'include', // Important: Include cookies
|
credentials: 'include', // Important: Include cookies
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
@ -302,12 +315,23 @@ class ApiService {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData: ApiResponse = await response.json().catch(() => ({}));
|
const errorData: any = await response.json().catch(() => ({}));
|
||||||
// Hide detailed error messages in production
|
const frappeMsg =
|
||||||
const errorMessage = import.meta.env.DEV
|
typeof errorData?.message === 'string'
|
||||||
? (errorData.error || `HTTP error! status: ${response.status}`)
|
? errorData.message
|
||||||
: 'Invalid credentials. Please try again.';
|
: Array.isArray(errorData?.errors) && errorData.errors[0]?.message
|
||||||
throw new ApiError(errorMessage, response.status);
|
? 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();
|
const data: any = await response.json();
|
||||||
@ -348,13 +372,16 @@ class ApiService {
|
|||||||
return { message: data } as LoginResponse;
|
return { message: data } as LoginResponse;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
// Only log detailed errors in development
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.error('[API Service] Login error:', error.message);
|
console.error('[API Service] Login error:', error.message);
|
||||||
}
|
}
|
||||||
|
const isAbort = error.name === 'AbortError';
|
||||||
throw new ApiError(
|
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;
|
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