Workflow timeline visualization#

The frontend counterpart to the backend approval workflow. A modal that fetches the workflow steps for a record and renders them as a Mantine timeline.

Component#

// src/components/workflow/WorkflowTimeline.tsx
import { useEffect, useState } from 'react';
import { Badge, Button, Modal, Text, Timeline } from '@mantine/core';
import { IconCheck, IconX, IconClock } from '@tabler/icons-react';
import AppStoreFactory from '../../wire/StoreFactory';

interface Props {
  entityId:  string | number;
  endPoint:  string;            // e.g. '/invoice/{invoiceId}/workflow'
  open:      boolean;
  onClose:   () => void;
  title?:    string;
}

const statusIcon: Record<string, any> = {
  COMPLETED: <IconCheck size={14} />,
  APPROVED:  <IconCheck size={14} />,
  REJECTED:  <IconX size={14} />,
  PENDING:   <IconClock size={14} />,
};

const statusColor: Record<string, string> = {
  COMPLETED: 'green', APPROVED: 'green', REJECTED: 'red', PENDING: 'gray',
};

interface Step {
  stepOrder:  number;
  groupName:  string;
  status:     string;
  actedBy?:   string;
  actedAt?:   string;
  remarks?:   string;
}

export default function WorkflowTimeline({ entityId, endPoint, open, onClose, title }: Props) {
  const [steps, setSteps] = useState<Step[]>([]);

  useEffect(() => {
    if (!open) return;
    const store = AppStoreFactory.getGridStore(
      { endPointOptions: { invoiceId: entityId } },
      endPoint
    );
    store.query({ sortOrder: { stepOrder: 'asc' }, limit: 100 }).then(r => {
      setSteps(r.result ?? []);
    });
  }, [open, entityId]);

  return (
    <Modal opened={open} onClose={onClose} title={title ?? 'Approval History'} size="lg">
      {steps.length === 0 ? (
        <Text c="dimmed">No workflow history found.</Text>
      ) : (
        <Timeline active={steps.length - 1} bulletSize={24} lineWidth={2}>
          {steps.map((step, i) => (
            <Timeline.Item
              key={i}
              bullet={statusIcon[step.status] ?? <IconClock size={14} />}
              color={statusColor[step.status] ?? 'gray'}
              title={
                <>
                  {step.groupName}
                  <Badge ml="xs" size="xs" color={statusColor[step.status] ?? 'gray'}>
                    {step.status}
                  </Badge>
                </>
              }
            >
              {step.actedBy && <Text size="sm">By: {step.actedBy}</Text>}
              {step.actedAt && <Text size="xs" c="dimmed">{new Date(step.actedAt).toLocaleString()}</Text>}
              {step.remarks && <Text size="sm" mt="xs" fs="italic">{step.remarks}</Text>}
            </Timeline.Item>
          ))}
        </Timeline>
      )}
    </Modal>
  );
}

Using it on a detail page#

import { useDisclosure } from '@mantine/hooks';
import WorkflowTimeline from '../../components/workflow/WorkflowTimeline';

function InvoiceViewPage() {
  const params = useParams();
  const [opened, { open, close }] = useDisclosure(false);

  return (
    <>
      <Button variant="subtle" onClick={open}>View approval history</Button>
      <WorkflowTimeline
        entityId={params.id!}
        endPoint="/invoice/{invoiceId}/workflow"
        open={opened}
        onClose={close}
        title="Invoice Approval History"
      />
    </>
  );
}

See also: Approval workflow, PalmyraGridStore.