diff --git a/frontend/src/components/EntityPage/EnhancedEntityEditShell.tsx b/frontend/src/components/EntityPage/EnhancedEntityEditShell.tsx new file mode 100644 index 0000000..3a89701 --- /dev/null +++ b/frontend/src/components/EntityPage/EnhancedEntityEditShell.tsx @@ -0,0 +1,96 @@ +import { mdiArrowLeft, mdiEyeOutline } from '@mdi/js'; +import React from 'react'; +import BaseButton from '../BaseButton'; +import BaseButtons from '../BaseButtons'; +import CardBox from '../CardBox'; +import { + emptyValue, + formatPrimitiveValue, + getPrimaryTitle, + getRecordSubtitle, + getSummaryEntries, + humanizeLabel, +} from '../EntityPageUtils'; + +type Props = { + entityLabel: string; + pluralLabel: string; + listHref: string; + viewHref?: string; + record: any; + children: React.ReactNode; +}; + +const EnhancedEntityEditShell = ({ entityLabel, pluralLabel, listHref, viewHref, record, children }: Props) => { + const title = getPrimaryTitle(record, `Edit ${entityLabel}`); + const subtitle = getRecordSubtitle(record, `${entityLabel} record`); + const summaryEntries = getSummaryEntries(record, 7); + + return ( +
+
+
+
+
+

Edit workspace

+

{title}

+

{subtitle}. Update the fields below and keep the record details clear, complete, and ready for downstream users.

+
+ + + {viewHref ? : null} + +
+
+ +
+ {children} +
+
+ +
+ +
+
+
+ +
+
+

Editing summary

+

Key values stay visible while the form is being updated.

+
+
+ +
+ {summaryEntries.length > 0 ? ( + summaryEntries.map(([key, value]) => ( +
+

{humanizeLabel(key)}

+
{value instanceof Date || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? formatPrimitiveValue(key, value) : value || emptyValue}
+
+ )) + ) : ( +
+ Summary fields will appear here as soon as this record has saved content. +
+ )} +
+
+
+ + +
+

Editing guidance

+
    +
  • • Keep naming and reference fields consistent with list and dashboard views.
  • +
  • • Use notes and description fields to add decision-quality context, not placeholders.
  • +
  • • Reset only if you want to discard unsaved changes in this form session.
  • +
+
+
+
+
+ ); +}; + +export default EnhancedEntityEditShell; diff --git a/frontend/src/components/EntityPage/EntityRecordViewPage.tsx b/frontend/src/components/EntityPage/EntityRecordViewPage.tsx new file mode 100644 index 0000000..bf2749c --- /dev/null +++ b/frontend/src/components/EntityPage/EntityRecordViewPage.tsx @@ -0,0 +1,282 @@ +import { mdiArrowLeft, mdiChartTimelineVariant, mdiPencil } from '@mdi/js'; +import Head from 'next/head'; +import React, { useEffect } from 'react'; +import BaseButton from '../BaseButton'; +import BaseButtons from '../BaseButtons'; +import BaseIcon from '../BaseIcon'; +import CardBox from '../CardBox'; +import SectionMain from '../SectionMain'; +import SectionTitleLineWithButton from '../SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { + emptyValue, + formatPrimitiveValue, + getChipEntries, + getCollectionPreview, + getMetricCards, + getPrimaryTitle, + getRecordEntries, + getRecordSubtitle, + getRelationLabel, + getSummaryEntries, + humanizeLabel, + isRecordObject, +} from '../EntityPageUtils'; + +type Props = { + singularLabel: string; + pluralLabel: string; + stateKey: string; + recordKey: string; + fetchRecord: any; + listHref: string; + editHref: (id: string | string[] | undefined) => string; +}; + +const getBadgeClassName = (value?: string | null) => { + switch (String(value || '').toLowerCase()) { + case 'active': + case 'approved': + case 'completed': + case 'resolved': + case 'settled': + case 'paid': + case 'enabled': + case 'true': + return 'border-emerald-200 bg-emerald-50 text-emerald-700'; + case 'submitted': + case 'under_review': + case 'in_progress': + case 'validated': + case 'partial': + case 'draft': + return 'border-blue-200 bg-blue-50 text-blue-700'; + case 'on_hold': + case 'blocked': + case 'follow_up_required': + case 'disputed': + case 'pending': + return 'border-amber-200 bg-amber-50 text-amber-700'; + case 'critical': + case 'high': + case 'terminated': + case 'cancelled': + case 'closed': + case 'disabled': + case 'false': + return 'border-red-200 bg-red-50 text-red-700'; + default: + return 'border-slate-200 bg-slate-50 text-slate-700 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200'; + } +}; + +const StatusBadge = ({ value }: { value: any }) => ( + {String(formatPrimitiveValue('status', value))} +); + +const DetailItem = ({ label, value }: { label: string; value: React.ReactNode }) => ( +
+

{label}

+
{value || emptyValue}
+
+); + +const MetricCard = ({ label, value, note }: { label: string; value: string; note: string }) => ( +
+

{label}

+

{value}

+

{note}

+
+); + +const EntityRecordViewPage = ({ singularLabel, pluralLabel, stateKey, recordKey, fetchRecord, listHref, editHref }: Props) => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { id } = router.query; + const sliceState = useAppSelector((state) => (state as any)[stateKey]); + const record = sliceState?.[recordKey]; + + useEffect(() => { + if (id) { + dispatch(fetchRecord({ id })); + } + }, [dispatch, fetchRecord, id]); + + const recordTitle = getPrimaryTitle(record, `View ${singularLabel}`); + const recordSubtitle = getRecordSubtitle(record, `${singularLabel} details`); + const summaryEntries = getSummaryEntries(record, 8); + const metricCards = getMetricCards(record); + const chipEntries = getChipEntries(record); + const { scalarEntries, relationEntries, collectionEntries } = getRecordEntries(record); + const overviewEntries = summaryEntries; + const detailEntries = scalarEntries.filter(([key]) => !overviewEntries.some(([entryKey]) => entryKey === key)); + + return ( + <> + + {getPageTitle(recordTitle)} + + + + + router.push(listHref)} /> + + + + +
+ +
+
+
+

{singularLabel} workspace

+

{recordTitle}

+

{recordSubtitle}

+
+ {chipEntries.length > 0 && ( +
+ {chipEntries.map(([key, value]) => ( +
+ {humanizeLabel(key)} + +
+ ))} +
+ )} +
+ +
+ {metricCards.map((metric) => ( + + ))} +
+
+
+ + {!isRecordObject(record) ? ( + +
+ Loading {singularLabel.toLowerCase()} details or waiting for the record to become available. +
+
+ ) : ( +
+
+ +
+
+

Overview

+

Key information is grouped here for faster review and cleaner scanning.

+
+
+ {overviewEntries.map(([key, value]) => ( + + ))} +
+
+
+ + {detailEntries.length > 0 && ( + +
+
+

Extended details

+

Additional record content is still available, but presented in a cleaner structured layout.

+
+
+ {detailEntries.map(([key, value]) => ( + + ))} +
+
+
+ )} + + {collectionEntries.map(([key, value]) => { + const items = Array.isArray(value) ? value : []; + const preview = getCollectionPreview(items); + + return ( + +
+
+
+

{humanizeLabel(key)}

+

Related records are presented as compact cards instead of a raw data dump.

+
+ + {items.length} records + +
+ + {preview.length > 0 ? ( +
+ {preview.map((item) => ( +
+
+

{item.title}

+
+ +
+
+ {item.details.length > 0 ? ( +
+ {item.details.map((detail) => ( +
+
{detail.label}
+
{detail.value || emptyValue}
+
+ ))} +
+ ) : ( +

No additional preview fields are available for this related record.

+ )} +
+ ))} +
+ ) : ( +
+ No related records have been attached here yet. +
+ )} +
+
+ ); + })} +
+ +
+ +
+
+

Connected records

+

Direct linked records are summarized here so users can see context without parsing nested JSON-like sections.

+
+
+ {relationEntries.length > 0 ? ( + relationEntries.map(([key, value]) => ( +
+

{humanizeLabel(key)}

+
{getRelationLabel(value)}
+
+ )) + ) : ( +
+ No linked records are available on this item yet. +
+ )} +
+
+
+
+
+ )} +
+
+ + ); +}; + +export default EntityRecordViewPage; diff --git a/frontend/src/components/EntityPageUtils.tsx b/frontend/src/components/EntityPageUtils.tsx new file mode 100644 index 0000000..2736338 --- /dev/null +++ b/frontend/src/components/EntityPageUtils.tsx @@ -0,0 +1,317 @@ +import React from 'react'; +import dayjs from 'dayjs'; + +const EMPTY_VALUE = 'Not yet recorded'; +const SYSTEM_FIELDS = new Set([ + 'id', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'created_at', + 'updated_at', + 'deleted_at', + 'password', + 'passwordHash', + 'salt', +]); + +const PRIMARY_TITLE_FIELDS = [ + 'name', + 'title', + 'label', + 'subject', + 'project_name', + 'program_name', + 'contract_title', + 'contract_number', + 'code', + 'project_code', + 'email', + 'firstName', + 'lastName', +]; + +const SUMMARY_FIELD_PRIORITY = [ + 'name', + 'title', + 'label', + 'code', + 'project_code', + 'contract_number', + 'status', + 'state', + 'phase', + 'priority', + 'risk_level', + 'type', + 'category', + 'organization', + 'program', + 'project', + 'vendor', + 'department', + 'assigned_to', + 'requested_by', + 'requested_at', + 'due_date', + 'start_date', + 'end_date', + 'amount', + 'budget_amount', + 'contract_value', + 'currency', +]; + +const CHIP_FIELDS = ['status', 'state', 'phase', 'priority', 'risk_level', 'type', 'category']; + +const DATE_SUFFIXES = ['_at', '_date']; +const DATE_KEYWORDS = ['date', 'time', 'deadline']; +const AMOUNT_KEYWORDS = ['amount', 'value', 'cost', 'budget', 'total', 'price', 'percent']; + +export const emptyValue = EMPTY_VALUE; + +export const titleCase = (value?: string | null) => { + if (!value) { + return ''; + } + + return value + .replace(/_/g, ' ') + .replace(/\b\w/g, (match) => match.toUpperCase()); +}; + +export const singularizeLabel = (value: string) => { + if (value.endsWith('ies')) { + return `${value.slice(0, -3)}y`; + } + + if (value.endsWith('ches') || value.endsWith('shes') || value.endsWith('xes') || value.endsWith('zes')) { + return value.slice(0, -2); + } + + if (value.endsWith('ses') && !value.endsWith('issues') && !value.endsWith('buses')) { + return value.slice(0, -1); + } + + if (value.endsWith('s')) { + return value.slice(0, -1); + } + + return value; +}; + +export const humanizeLabel = (key: string) => { + return titleCase( + key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .replace(/\bid\b/i, 'ID') + .trim(), + ); +}; + +export const isRecordObject = (value: any) => { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); +}; + +export const isDateLikeKey = (key: string) => { + return DATE_SUFFIXES.some((suffix) => key.endsWith(suffix)) || DATE_KEYWORDS.some((part) => key.includes(part)); +}; + +export const isAmountLikeKey = (key: string) => { + return AMOUNT_KEYWORDS.some((part) => key.includes(part)); +}; + +export const stripHtml = (value: string) => value.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + +export const formatPrimitiveValue = (key: string, value: any): React.ReactNode => { + if (value === null || value === undefined || value === '') { + return EMPTY_VALUE; + } + + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + + if (typeof value === 'number') { + if (key.toLowerCase().includes('percent')) { + return `${value}%`; + } + + return value.toLocaleString(); + } + + if (value instanceof Date || (typeof value === 'string' && isDateLikeKey(key) && dayjs(value).isValid())) { + return dayjs(value).format('DD MMM YYYY HH:mm'); + } + + if (typeof value === 'string') { + if (value.trim().startsWith('<') && value.includes('>')) { + const stripped = stripHtml(value); + return stripped || EMPTY_VALUE; + } + + return value; + } + + return String(value); +}; + +export const getRelationLabel = (value: any) => { + if (!isRecordObject(value)) { + return EMPTY_VALUE; + } + + const candidates = ['name', 'title', 'label', 'code', 'project_code', 'contract_number', 'email', 'id']; + for (const candidate of candidates) { + if (value[candidate]) { + return formatPrimitiveValue(candidate, value[candidate]); + } + } + + const visibleEntry = Object.entries(value).find(([entryKey, entryValue]) => !SYSTEM_FIELDS.has(entryKey) && !isRecordObject(entryValue) && !Array.isArray(entryValue)); + return visibleEntry ? formatPrimitiveValue(visibleEntry[0], visibleEntry[1]) : EMPTY_VALUE; +}; + +export const getPrimaryTitle = (record: any, fallback: string) => { + if (!isRecordObject(record)) { + return fallback; + } + + const firstName = record.firstName || record.first_name; + const lastName = record.lastName || record.last_name; + if (firstName || lastName) { + return [firstName, lastName].filter(Boolean).join(' '); + } + + for (const field of PRIMARY_TITLE_FIELDS) { + if (record[field]) { + const value = formatPrimitiveValue(field, record[field]); + return typeof value === 'string' ? value : fallback; + } + } + + return fallback; +}; + +export const getRecordSubtitle = (record: any, fallback: string) => { + if (!isRecordObject(record)) { + return fallback; + } + + const referenceFields = ['code', 'project_code', 'contract_number', 'email', 'id']; + const reference = referenceFields.find((field) => record[field]); + if (!reference) { + return fallback; + } + + return `${humanizeLabel(reference)}: ${formatPrimitiveValue(reference, record[reference])}`; +}; + +export const getRecordEntries = (record: any) => { + if (!isRecordObject(record)) { + return { scalarEntries: [], relationEntries: [], collectionEntries: [] }; + } + + const scalarEntries = Object.entries(record).filter(([key, value]) => { + return !SYSTEM_FIELDS.has(key) && !Array.isArray(value) && !isRecordObject(value); + }); + + const relationEntries = Object.entries(record).filter(([key, value]) => { + return !SYSTEM_FIELDS.has(key) && isRecordObject(value); + }); + + const collectionEntries = Object.entries(record).filter(([key, value]) => { + return !SYSTEM_FIELDS.has(key) && Array.isArray(value); + }); + + return { scalarEntries, relationEntries, collectionEntries }; +}; + +export const sortEntries = (entries: Array<[string, any]>) => { + return [...entries].sort((left, right) => { + const leftIndex = SUMMARY_FIELD_PRIORITY.indexOf(left[0]); + const rightIndex = SUMMARY_FIELD_PRIORITY.indexOf(right[0]); + + if (leftIndex !== -1 || rightIndex !== -1) { + return (leftIndex === -1 ? 999 : leftIndex) - (rightIndex === -1 ? 999 : rightIndex); + } + + return humanizeLabel(left[0]).localeCompare(humanizeLabel(right[0])); + }); +}; + +export const getSummaryEntries = (record: any, limit = 8) => { + const { scalarEntries, relationEntries } = getRecordEntries(record); + const scalar = sortEntries(scalarEntries); + const relation = sortEntries(relationEntries) + .filter(([key]) => !CHIP_FIELDS.includes(key)) + .map(([key, value]) => [key, getRelationLabel(value)] as [string, React.ReactNode]); + + return [...scalar, ...relation].slice(0, limit); +}; + +export const getChipEntries = (record: any) => { + if (!isRecordObject(record)) { + return [] as Array<[string, any]>; + } + + return CHIP_FIELDS.filter((field) => record[field]).map((field) => [field, record[field]] as [string, any]); +}; + +export const getMetricCards = (record: any) => { + const { collectionEntries } = getRecordEntries(record); + const metrics = [ + { + label: 'Linked sections', + value: collectionEntries.length.toString(), + note: collectionEntries.length ? 'Related collections are shown below.' : 'No related collections are attached yet.', + }, + ]; + + const importantEntries = [ + ['status', record?.status], + ['start_date', record?.start_date], + ['end_date', record?.end_date], + ['budget_amount', record?.budget_amount ?? record?.amount ?? record?.contract_value], + ].filter(([, value]) => value !== undefined && value !== null && value !== ''); + + for (const [key, value] of importantEntries.slice(0, 3)) { + metrics.push({ + label: humanizeLabel(key), + value: String(formatPrimitiveValue(key, value)), + note: key === 'budget_amount' ? 'Tracked financial figure for this record.' : 'Key operating signal for this record.', + }); + } + + return metrics.slice(0, 4); +}; + +export const getCollectionPreview = (items: any[]) => { + return items.slice(0, 6).map((item, index) => { + if (!isRecordObject(item)) { + return { + key: String(index), + title: formatPrimitiveValue('value', item), + details: [], + }; + } + + const title = getPrimaryTitle(item, `Record ${index + 1}`); + const details = sortEntries( + Object.entries(item).filter(([key, value]) => !SYSTEM_FIELDS.has(key) && !Array.isArray(value) && !isRecordObject(value)), + ) + .filter(([key]) => !PRIMARY_TITLE_FIELDS.includes(key)) + .slice(0, 4) + .map(([key, value]) => ({ + label: humanizeLabel(key), + value: formatPrimitiveValue(key, value), + })); + + return { + key: String(item.id || index), + title, + details, + }; + }); +}; diff --git a/frontend/src/pages/allocations/allocations-edit.tsx b/frontend/src/pages/allocations/allocations-edit.tsx index b2b879d..db1cf0c 100644 --- a/frontend/src/pages/allocations/allocations-edit.tsx +++ b/frontend/src/pages/allocations/allocations-edit.tsx @@ -10,6 +10,7 @@ import LayoutAuthenticated from '../../layouts/Authenticated' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import { getPageTitle } from '../../config' +import EnhancedEntityEditShell from '../../components/EntityPage/EnhancedEntityEditShell' import { Field, Form, Formik } from 'formik' import FormField from '../../components/FormField' @@ -343,29 +344,14 @@ const EditAllocationsPage = () => { onSubmit={(values) => handleSubmit(values)} >
- - - - - - - - - - - - - - - - - - - - - - - + + { router.push('/allocations/allocations-list')}/> + diff --git a/frontend/src/pages/allocations/allocations-view.tsx b/frontend/src/pages/allocations/allocations-view.tsx index c1c49d2..d358e3c 100644 --- a/frontend/src/pages/allocations/allocations-view.tsx +++ b/frontend/src/pages/allocations/allocations-view.tsx @@ -1,841 +1,22 @@ -import React, { ReactElement, useEffect } from 'react'; -import Head from 'next/head' -import DatePicker from "react-datepicker"; -import "react-datepicker/dist/react-datepicker.css"; -import dayjs from "dayjs"; -import {useAppDispatch, useAppSelector} from "../../stores/hooks"; -import {useRouter} from "next/router"; -import { fetch } from '../../stores/allocations/allocationsSlice' -import {saveFile} from "../../helpers/fileSaver"; -import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from "../../components/ImageField"; -import LayoutAuthenticated from "../../layouts/Authenticated"; -import {getPageTitle} from "../../config"; -import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton"; -import SectionMain from "../../components/SectionMain"; -import CardBox from "../../components/CardBox"; -import BaseButton from "../../components/BaseButton"; -import BaseDivider from "../../components/BaseDivider"; -import {mdiChartTimelineVariant} from "@mdi/js"; -import {SwitchField} from "../../components/SwitchField"; -import FormField from "../../components/FormField"; - -import {hasPermission} from "../../helpers/userPermissions"; - - -const AllocationsView = () => { - const router = useRouter() - const dispatch = useAppDispatch() - const { allocations } = useAppSelector((state) => state.allocations) - - const { currentUser } = useAppSelector((state) => state.auth); - - - const { id } = router.query; - - function removeLastCharacter(str) { - console.log(str,`str`) - return str.slice(0, -1); - } - - useEffect(() => { - dispatch(fetch({ id })); - }, [dispatch, id]); - - - return ( - <> - - {getPageTitle('View allocations')} - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

BudgetLine

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

{allocations?.budget_line?.name ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Province

- - - - - - - - - - -

{allocations?.province?.name ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Department

- - - - - - - - - - - - -

{allocations?.department?.name ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - -
-

AllocationAmount

-

{allocations?.amount || 'No data'}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Currency

-

{allocations?.currency ?? 'No data'}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - {allocations.allocated_at ? :

No AllocatedAt

} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Status

-

{allocations?.status ?? 'No data'}

-
- - - - - - - - - - - - - - - - - -