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:
Duradundi Hadimani 2026-03-27 12:32:50 +00:00
parent cbffc877fc
commit d0aa68b37c
13 changed files with 1866 additions and 21 deletions

View File

@ -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 />} />

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

View File

@ -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'),

View File

@ -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: {

View File

@ -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": "أصول سيرا",

View File

@ -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",

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

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

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

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

View File

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

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

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