diff --git a/src/actions/bmdashboard/costBreakdownActions.js b/src/actions/bmdashboard/costBreakdownActions.js new file mode 100644 index 0000000000..58d88dab14 --- /dev/null +++ b/src/actions/bmdashboard/costBreakdownActions.js @@ -0,0 +1,43 @@ +import axios from 'axios'; +import { ENDPOINTS } from '../../utils/URL'; +import { + FETCH_COST_BREAKDOWN_START, + FETCH_COST_BREAKDOWN_SUCCESS, + FETCH_COST_BREAKDOWN_ERROR, + FETCH_COST_DETAIL_START, + FETCH_COST_DETAIL_SUCCESS, + FETCH_COST_DETAIL_ERROR, + CLEAR_COST_DETAIL, +} from '../../constants/bmdashboard/costBreakdownConstants'; + +export const fetchCostBreakdown = ({ projectId, startDate, endDate } = {}) => { + return async dispatch => { + dispatch({ type: FETCH_COST_BREAKDOWN_START }); + try { + const url = ENDPOINTS.BM_COST_BREAKDOWN(projectId, startDate, endDate); + const res = await axios.get(url); + dispatch({ type: FETCH_COST_BREAKDOWN_SUCCESS, payload: res.data }); + } catch (error) { + const msg = error.response?.data?.error || 'Failed to load cost breakdown'; + dispatch({ type: FETCH_COST_BREAKDOWN_ERROR, payload: msg }); + } + }; +}; + +export const fetchCostDetail = ({ projectId, startDate, endDate } = {}) => { + return async dispatch => { + dispatch({ type: FETCH_COST_DETAIL_START }); + try { + const url = ENDPOINTS.BM_COST_BREAKDOWN(projectId, startDate, endDate, true); + const res = await axios.get(url); + dispatch({ type: FETCH_COST_DETAIL_SUCCESS, payload: res.data }); + } catch (error) { + const msg = error.response?.data?.error || 'Failed to load category details'; + dispatch({ type: FETCH_COST_DETAIL_ERROR, payload: msg }); + } + }; +}; + +export const clearCostDetail = () => ({ + type: CLEAR_COST_DETAIL, +}); diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostBreakDown/CostBreakDown.jsx b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostBreakDown/CostBreakDown.jsx new file mode 100644 index 0000000000..690d160d55 --- /dev/null +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostBreakDown/CostBreakDown.jsx @@ -0,0 +1,476 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; +import axios from 'axios'; +import { + fetchCostBreakdown, + fetchCostDetail, + clearCostDetail, +} from '../../../../../actions/bmdashboard/costBreakdownActions'; +import styles from './CostBreakDown.module.css'; + +const CATEGORY_CONFIG = { + 'Total Cost of Labor': { label: 'Labor', color: '#9333EA' }, + 'Total Cost of Materials': { label: 'Materials', color: '#22C55E' }, + 'Total Cost of Equipment': { label: 'Equipment', color: '#EAB308' }, +}; + +const DEBOUNCE_MS = 500; +const CHAR_WIDTH_RATIO = 0.55; +const MAX_LABEL_LINES = 2; + +function fitLabelInDonut(text, availableWidth, maxFontSize, minFontSize) { + const words = text.split(/\s+/); + + for (let size = maxFontSize; size >= minFontSize; size -= 0.5) { + const charsPerLine = Math.floor(availableWidth / (size * CHAR_WIDTH_RATIO)); + if (charsPerLine < 1) continue; + + const lines = []; + let currentLine = ''; + for (let w = 0; w < words.length; w += 1) { + const testLine = currentLine ? `${currentLine} ${words[w]}` : words[w]; + if (testLine.length <= charsPerLine) { + currentLine = testLine; + } else if (!currentLine) { + currentLine = words[w]; + } else { + lines.push(currentLine); + currentLine = words[w]; + } + } + if (currentLine) lines.push(currentLine); + + if (lines.length <= MAX_LABEL_LINES) { + return { fontSize: size, lines }; + } + } + + // At min size, wrap and truncate to MAX_LABEL_LINES + const charsPerLine = Math.max(1, Math.floor(availableWidth / (minFontSize * CHAR_WIDTH_RATIO))); + const lines = []; + let currentLine = ''; + for (let w = 0; w < words.length; w += 1) { + const testLine = currentLine ? `${currentLine} ${words[w]}` : words[w]; + if (testLine.length <= charsPerLine) { + currentLine = testLine; + } else if (!currentLine) { + currentLine = words[w]; + } else { + lines.push(currentLine); + currentLine = words[w]; + } + } + if (currentLine) lines.push(currentLine); + + const truncated = lines.slice(0, MAX_LABEL_LINES); + if (lines.length > MAX_LABEL_LINES) { + const last = truncated[MAX_LABEL_LINES - 1]; + truncated[MAX_LABEL_LINES - 1] = + last.length > charsPerLine - 1 ? `${last.slice(0, charsPerLine - 1)}\u2026` : `${last}\u2026`; + } + return { fontSize: minFontSize, lines: truncated }; +} + +function formatCurrency(amount) { + if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '$0'; + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(Number(amount)); + } catch (e) { + return `$${Number(amount).toFixed(0)}`; + } +} + +function CustomTooltip({ active, payload, totalCost, darkMode }) { + if (!active || !payload?.length) return null; + const entry = payload[0].payload; + const config = CATEGORY_CONFIG[entry.name] || {}; + const percentage = totalCost > 0 ? ((entry.value / totalCost) * 100).toFixed(1) : '0.0'; + + return ( +
+
{config.label || entry.name}
+
+ Amount: {formatCurrency(entry.value)} +
+
+ Share: {percentage}% +
+
+ ); +} + +export default function CostBreakDown() { + const dispatch = useDispatch(); + + const { loading, data, error, detailLoading, detailData, detailError } = useSelector( + state => state.costBreakdown, + ); + const darkMode = useSelector(state => state.theme.darkMode); + + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [activeIndex, setActiveIndex] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null); + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + + const debounceRef = useRef(null); + + // Load project list for dropdown + useEffect(() => { + let cancelled = false; + const apiBase = (process.env.REACT_APP_APIENDPOINT || '').replace(/\/$/, ''); + axios + .get(`${apiBase}/bm/projectsNames`) + .then(res => { + if (cancelled) return; + if (Array.isArray(res.data)) { + const list = res.data + .filter(p => p?.projectId) + .map(p => ({ id: p.projectId, name: p.projectName || p.projectId })); + setProjects(list); + } + }) + .catch(() => { + // Silently fail — dropdown shows only "All Projects" + }); + return () => { + cancelled = true; + }; + }, []); + + // Fetch cost breakdown with debouncing + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + dispatch(fetchCostBreakdown({ projectId, startDate, endDate })); + }, DEBOUNCE_MS); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [dispatch, projectId, startDate, endDate]); + + // Window resize listener + useEffect(() => { + const handleResize = () => setWindowWidth(window.innerWidth); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const chartData = useMemo(() => { + if (!data?.breakdown?.length) return []; + return data.breakdown.map(item => ({ + name: item.category, + shortName: (CATEGORY_CONFIG[item.category] || {}).label || item.category, + value: item.amount, + color: (CATEGORY_CONFIG[item.category] || {}).color || '#999', + })); + }, [data]); + + const totalCost = data?.totalCost || 0; + const projectLabel = data?.project || 'All Projects'; + + const isXS = windowWidth <= 480; + let innerRadius; + let outerRadius; + let chartHeight; + if (isXS) { + innerRadius = 50; + outerRadius = 75; + chartHeight = 250; + } else if (windowWidth <= 768) { + innerRadius = 60; + outerRadius = 90; + chartHeight = 280; + } else { + innerRadius = 70; + outerRadius = 110; + chartHeight = 320; + } + + const { fontSize: labelFontSize, lines: labelLines } = useMemo(() => { + const maxSize = isXS ? 12 : 14; + const availableWidth = innerRadius * 2 * 0.75; + return fitLabelInDonut(projectLabel, availableWidth, maxSize, 7); + }, [projectLabel, innerRadius, isXS]); + + const handleSliceClick = useCallback( + entry => { + const category = entry.name; + if (selectedCategory === category) { + setSelectedCategory(null); + dispatch(clearCostDetail()); + return; + } + setSelectedCategory(category); + dispatch(fetchCostDetail({ projectId, startDate, endDate })); + }, + [dispatch, projectId, startDate, endDate, selectedCategory], + ); + + const handleFilterChange = useCallback( + (setter, value) => { + setter(value); + setSelectedCategory(null); + dispatch(clearCostDetail()); + }, + [dispatch], + ); + + const hasActiveFilters = projectId || startDate || endDate; + + const handleClearFilters = useCallback(() => { + setProjectId(''); + setStartDate(''); + setEndDate(''); + setSelectedCategory(null); + dispatch(clearCostDetail()); + }, [dispatch]); + + const renderProjectBreakdown = () => { + const categoryData = detailData?.breakdown?.find(b => b.category === selectedCategory); + if (!categoryData?.projectBreakdown?.length) { + return
No project breakdown available
; + } + return ( +
+ {categoryData.projectBreakdown.map(proj => ( +
+ {proj.projectName} + {formatCurrency(proj.amount)} + {proj.percentage.toFixed(1)}% +
+ ))} +
+ ); + }; + + const wrapperClass = `${styles.wrapper} ${darkMode ? styles.wrapperDark : ''}`; + + return ( +
+

Cost Breakdown by Category

+ + {/* Filters */} +
+
+ + +
+ +
+ + handleFilterChange(setStartDate, e.target.value)} + className={styles.filterInput} + /> +
+ +
+ + handleFilterChange(setEndDate, e.target.value)} + className={styles.filterInput} + /> +
+ + {hasActiveFilters && ( + + )} +
+ + {/* Loading */} + {loading && ( +
+
+ Loading cost data... +
+ )} + + {/* Error */} + {!loading && error && ( +
+

{error}

+ +
+ )} + + {/* Empty state */} + {!loading && !error && data && chartData.length === 0 && ( +
+

No cost data available

+

+ {projectId + ? 'Try selecting a different project or date range' + : 'No cost records found'} +

+
+ )} + + {/* Chart */} + {!loading && !error && chartData.length > 0 && ( + <> +
+ + + setActiveIndex(i)} + onMouseLeave={() => setActiveIndex(null)} + onClick={(_, i) => handleSliceClick(chartData[i])} + isAnimationActive={false} + > + {chartData.map((entry, i) => ( + + ))} + + + {/* Center label: project name */} + 1 ? '40%' : '46%'} + textAnchor="middle" + dominantBaseline="middle" + fill={darkMode ? '#f8fafc' : '#0f172a'} + fontSize={labelFontSize} + fontWeight="600" + > + {labelLines.map((line, idx) => ( + + {line} + + ))} + + + {/* Center label: total cost */} + 1 ? '57%' : '55%'} + textAnchor="middle" + dominantBaseline="middle" + fill={darkMode ? '#f8fafc' : '#0f172a'} + fontSize={isXS ? 16 : 20} + fontWeight="800" + > + {formatCurrency(totalCost)} + + + } + cursor={false} + allowEscapeViewBox={{ x: false, y: false }} + /> + + +
+ + {/* Legend */} +
+ {chartData.map(entry => ( + + ))} +
+ + {/* Detail panel */} + {selectedCategory && ( +
+
+

+ {(CATEGORY_CONFIG[selectedCategory] || {}).label || selectedCategory} by Project +

+ +
+ + {detailLoading &&
Loading details...
} + {!detailLoading && detailError && ( +
{detailError}
+ )} + {!detailLoading && !detailError && detailData && renderProjectBreakdown()} +
+ )} + + )} +
+ ); +} diff --git a/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostBreakDown/CostBreakDown.module.css b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostBreakDown/CostBreakDown.module.css new file mode 100644 index 0000000000..f0eab09e5e --- /dev/null +++ b/src/components/BMDashboard/WeeklyProjectSummary/Financials/CostBreakDown/CostBreakDown.module.css @@ -0,0 +1,430 @@ +/* ===================== Wrapper ===================== */ +.wrapper { + --cb-text-color: #000; + --cb-input-bg: #fff; + --cb-input-border: #d1d5db; + --cb-tooltip-bg: #fff; + --cb-tooltip-border: #e5e7eb; + --cb-detail-bg: #f9fafb; + width: 100%; + height: 100%; + padding: 20px; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.wrapperDark { + --cb-text-color: #fff; + --cb-input-bg: #2b3e59; + --cb-input-border: #4a5a77; + --cb-tooltip-bg: #253342; + --cb-tooltip-border: #4a5a77; + --cb-detail-bg: #253342; + background: #2b3e59; +} + +/* ===================== Title ===================== */ +.title { + text-align: center; + font-weight: 600; + margin: 0 0 1rem; + font-size: 1.1rem; + color: var(--cb-text-color); +} + +/* ===================== Filters ===================== */ +.filters { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.filterItem { + display: flex; + flex-direction: column; + align-items: flex-start; + font-size: 14px; + min-width: 140px; +} + +.filterLabel { + font-weight: 600; + font-size: 13px; + color: var(--cb-text-color); + margin-bottom: 4px; +} + +.filterSelect, +.filterInput { + width: 100%; + padding: 6px 10px; + font-size: 14px; + border: 1px solid var(--cb-input-border); + border-radius: 6px; + background: var(--cb-input-bg); + color: var(--cb-text-color); + transition: border-color 0.15s ease; +} + +.filterSelect:focus, +.filterInput:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.filterSelect:hover, +.filterInput:hover { + border-color: #9ca3af; +} + +.clearFiltersButton { + align-self: flex-end; + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + border: 1px solid var(--cb-input-border); + border-radius: 6px; + background: transparent; + color: var(--cb-text-color); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.clearFiltersButton:hover { + background: rgba(0, 0, 0, 0.05); + border-color: #9ca3af; +} + +.wrapperDark .clearFiltersButton:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* ===================== Chart Container ===================== */ +.chartContainer { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; +} + +/* ===================== Tooltip ===================== */ +.tooltip { + background: var(--cb-tooltip-bg); + border: 1px solid var(--cb-tooltip-border); + border-radius: 8px; + padding: 10px 14px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + min-width: 140px; +} + +.tooltipTitle { + font-weight: 700; + font-size: 14px; + margin-bottom: 6px; + color: var(--cb-text-color); +} + +.tooltipRow { + font-size: 13px; + color: var(--cb-text-color); + margin-bottom: 2px; +} + +/* ===================== Legend ===================== */ +.legend { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +.legendItem { + color: #fff; + padding: 6px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + min-width: 120px; + text-align: center; + border: 2px solid transparent; + cursor: pointer; + transition: opacity 0.15s ease, border-color 0.15s ease; +} + +.legendItem:hover { + opacity: 0.85; +} + +.legendItemActive { + border-color: var(--cb-text-color); +} + +/* ===================== Detail Panel ===================== */ +.detailPanel { + margin-top: 1rem; + padding: 16px; + background: var(--cb-detail-bg); + border-radius: 8px; + border: 1px solid var(--cb-input-border); +} + +.detailHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.detailTitle { + font-size: 15px; + font-weight: 600; + color: var(--cb-text-color); + margin: 0; +} + +.detailClose { + background: none; + border: 1px solid var(--cb-input-border); + border-radius: 4px; + color: var(--cb-text-color); + font-size: 14px; + padding: 2px 8px; + cursor: pointer; + transition: background 0.15s ease; +} + +.detailClose:hover { + background: rgba(0, 0, 0, 0.05); +} + +.wrapperDark .detailClose:hover { + background: rgba(255, 255, 255, 0.1); +} + +.projectList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.projectRow { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 12px; + align-items: center; + padding: 8px 10px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.03); + color: var(--cb-text-color); +} + +.wrapperDark .projectRow { + background: rgba(255, 255, 255, 0.05); +} + +.projectName { + font-size: 14px; + font-weight: 500; +} + +.projectAmount { + font-size: 14px; + font-weight: 600; + text-align: right; +} + +.projectPct { + font-size: 13px; + color: #6b7280; + min-width: 50px; + text-align: right; +} + +.wrapperDark .projectPct { + color: #9ca3af; +} + +/* ===================== States ===================== */ +.loading, +.empty, +.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + color: var(--cb-text-color); + text-align: center; + gap: 8px; +} + +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--cb-input-border); + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.emptySubtext { + font-size: 13px; + color: #6b7280; +} + +.wrapperDark .emptySubtext { + color: #9ca3af; +} + +.errorMessage { + color: #b91c1c; + font-size: 14px; + margin: 0; +} + +.wrapperDark .errorMessage { + color: #fca5a5; +} + +.retryButton { + margin-top: 8px; + padding: 6px 16px; + font-size: 13px; + border: 1px solid var(--cb-input-border); + border-radius: 6px; + background: var(--cb-input-bg); + color: var(--cb-text-color); + cursor: pointer; + transition: background 0.15s ease; +} + +.retryButton:hover { + background: rgba(0, 0, 0, 0.05); +} + +.wrapperDark .retryButton:hover { + background: rgba(255, 255, 255, 0.1); +} + +.detailLoading, +.detailError { + padding: 12px; + text-align: center; + font-size: 14px; + color: var(--cb-text-color); +} + +.noDetail { + padding: 12px; + text-align: center; + font-size: 14px; + color: #6b7280; +} + +.wrapperDark .noDetail { + color: #9ca3af; +} + +/* ===================== Dark Mode: Native Widget Colors ===================== */ +.wrapperDark .filterSelect, +.wrapperDark .filterInput { + color-scheme: dark; +} + +.wrapperDark .filterInput[type='date']::-webkit-calendar-picker-indicator { + filter: invert(1) opacity(0.8); +} + +/* ===================== Responsive ===================== */ +@media (max-width: 768px) { + .filters { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .filterItem { + width: 100%; + min-width: auto; + } + + .wrapper { + padding: 14px; + } + + .projectRow { + grid-template-columns: 1fr; + gap: 4px; + } + + .projectAmount, + .projectPct { + text-align: left; + } +} + +@media (max-width: 480px) { + .title { + font-size: 0.95rem; + } + + .legend { + gap: 0.5rem; + } + + .legendItem { + min-width: 90px; + padding: 5px 12px; + font-size: 13px; + } +} + +/* ===================== Accessibility ===================== */ +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } + + .legendItem, + .filterSelect, + .filterInput, + .clearFiltersButton, + .detailClose, + .retryButton { + transition: none; + } +} + +@media (prefers-contrast: high) { + .wrapper { + border: 2px solid #000; + } + + .filterSelect, + .filterInput { + border: 2px solid #000; + } +} + +@media print { + .filters { + display: none; + } + + .detailClose { + display: none; + } +} diff --git a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx index a2b7aec91f..a61d05b02e 100644 --- a/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx +++ b/src/components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary.jsx @@ -12,6 +12,7 @@ import InjuryCategoryBarChart from './GroupedBarGraphInjurySeverity/InjuryCatego import ToolsHorizontalBarChart from './Tools/ToolsHorizontalBarChart'; import ExpenseBarChart from './Financials/ExpenseBarChart'; import FinancialStatButtons from './Financials/FinancialStatButtons'; +import CostBreakDown from './Financials/CostBreakDown/CostBreakDown'; import ActualVsPlannedCost from './ActualVsPlannedCost/ActualVsPlannedCost'; import TotalMaterialCostPerProject from './TotalMaterialCostPerProject/TotalMaterialCostPerProject'; import styles from './WeeklyProjectSummary.module.css'; @@ -379,7 +380,9 @@ function WeeklyProjectSummary() {
-
📊 Big Card
+
+ +
), }, diff --git a/src/constants/bmdashboard/costBreakdownConstants.js b/src/constants/bmdashboard/costBreakdownConstants.js new file mode 100644 index 0000000000..6eccca9da3 --- /dev/null +++ b/src/constants/bmdashboard/costBreakdownConstants.js @@ -0,0 +1,9 @@ +export const FETCH_COST_BREAKDOWN_START = 'FETCH_COST_BREAKDOWN_START'; +export const FETCH_COST_BREAKDOWN_SUCCESS = 'FETCH_COST_BREAKDOWN_SUCCESS'; +export const FETCH_COST_BREAKDOWN_ERROR = 'FETCH_COST_BREAKDOWN_ERROR'; + +export const FETCH_COST_DETAIL_START = 'FETCH_COST_DETAIL_START'; +export const FETCH_COST_DETAIL_SUCCESS = 'FETCH_COST_DETAIL_SUCCESS'; +export const FETCH_COST_DETAIL_ERROR = 'FETCH_COST_DETAIL_ERROR'; + +export const CLEAR_COST_DETAIL = 'CLEAR_COST_DETAIL'; diff --git a/src/reducers/bmdashboard/costBreakdownReducer.js b/src/reducers/bmdashboard/costBreakdownReducer.js new file mode 100644 index 0000000000..bbbca2f02b --- /dev/null +++ b/src/reducers/bmdashboard/costBreakdownReducer.js @@ -0,0 +1,46 @@ +import { + FETCH_COST_BREAKDOWN_START, + FETCH_COST_BREAKDOWN_SUCCESS, + FETCH_COST_BREAKDOWN_ERROR, + FETCH_COST_DETAIL_START, + FETCH_COST_DETAIL_SUCCESS, + FETCH_COST_DETAIL_ERROR, + CLEAR_COST_DETAIL, +} from '../../constants/bmdashboard/costBreakdownConstants'; + +const defaultState = { + loading: false, + data: null, + error: null, + + detailLoading: false, + detailData: null, + detailError: null, +}; + +// eslint-disable-next-line default-param-last +export const costBreakdownReducer = (state = defaultState, action) => { + switch (action.type) { + case FETCH_COST_BREAKDOWN_START: + return { ...state, loading: true, error: null }; + case FETCH_COST_BREAKDOWN_SUCCESS: + return { ...state, loading: false, data: action.payload, error: null }; + case FETCH_COST_BREAKDOWN_ERROR: + return { ...state, loading: false, data: null, error: action.payload }; + + case FETCH_COST_DETAIL_START: + return { ...state, detailLoading: true, detailError: null }; + case FETCH_COST_DETAIL_SUCCESS: + return { ...state, detailLoading: false, detailData: action.payload, detailError: null }; + case FETCH_COST_DETAIL_ERROR: + return { ...state, detailLoading: false, detailData: null, detailError: action.payload }; + + case CLEAR_COST_DETAIL: + return { ...state, detailLoading: false, detailData: null, detailError: null }; + + default: + return state; + } +}; + +export default costBreakdownReducer; diff --git a/src/reducers/index.js b/src/reducers/index.js index ca43404639..ec7ff0dc86 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -63,6 +63,7 @@ import { equipmentReducer } from './bmdashboard/equipmentReducer'; import { bmProjectMemberReducer } from './bmdashboard/projectMemberReducer'; import { bmTimeLoggerReducer } from './bmdashboard/timeLoggerReducer'; import bmInjuryReducer from './bmdashboard/injuryReducer'; +import { costBreakdownReducer } from './bmdashboard/costBreakdownReducer'; import dashboardReducer from './dashboardReducer'; import { timeOffRequestsReducer } from './timeOffRequestReducer'; @@ -165,6 +166,7 @@ const localReducers = { dashboard: dashboardReducer, injuries: injuriesReducer, weeklyProjectSummary: weeklyProjectSummaryReducer, + costBreakdown: costBreakdownReducer, // lbdashboard wishlistItem: wishListReducer, diff --git a/src/utils/URL.js b/src/utils/URL.js index 3124b4bd04..265b8dc6bf 100644 --- a/src/utils/URL.js +++ b/src/utils/URL.js @@ -346,6 +346,16 @@ export const ENDPOINTS = { BM_TOOLS_RETURNED_LATE: `${APIEndpoint}/bm/tools/returned-late`, BM_TOOLS_RETURNED_LATE_PROJECTS: `${APIEndpoint}/bm/tools/returned-late/projects`, TOOLS_AVAILABILITY_PROJECTS: `${APIEndpoint}/bm/tools-availability/projects`, + BM_COST_BREAKDOWN: (projectId, startDate, endDate, categoryDetail) => { + let url = `${APIEndpoint}/costs/breakdown`; + const params = []; + if (projectId) params.push(`projectId=${projectId}`); + if (startDate) params.push(`startDate=${startDate}`); + if (endDate) params.push(`endDate=${endDate}`); + if (categoryDetail) params.push('categoryDetail=true'); + if (params.length > 0) url += `?${params.join('&')}`; + return url; + }, TOOLS_AVAILABILITY_BY_PROJECT: (projectId, startDate, endDate) => { let url = `${APIEndpoint}/bm/projects/${projectId}/tools-availability`; const params = [];