empowered.house ↗

process/approval.ex

Approval

An approval request with its approvers and the outcome, for people-decide moments.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.process_approval animated />
</div>
default
isometric
waiting for one approver
sent back · custom copy
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Approval request"
title string "Purchase order PO-2291"
meta string "Requested by the intake agent"
amount string "€18,400"
approvers list [%{name: "Marta Nowak", status: "approved", role: "Team lead"}, %{name: "Jan Kowalski", ...}, ...] Up to three `%{name, role, status}`. Status is `approved`, `waiting` or `rejected`.
outcome string nil The footer line. Left empty, it is written from the approver statuses.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/process/approval.ex
approval.ex
defmodule EHVisuals.Process.Approval do
  @moduledoc """
  An approval request: what is asked, who has decided, and the outcome.
  """

  use EHVisuals.Composition,
    name: "process/approval",
    title: "Approval",
    description:
      "An approval request with its approvers and the outcome, for people-decide moments."

  @approvers [
    %{name: "Marta Nowak", role: "Team lead", status: "approved"},
    %{name: "Jan Kowalski", role: "Finance", status: "approved"},
    %{name: "Ewa Lis", role: "Operations", status: "approved"}
  ]

  attr(:eyebrow, :string, default: "Approval request")
  attr(:title, :string, default: "Purchase order PO-2291")
  attr(:meta, :string, default: "Requested by the intake agent")
  attr(:amount, :string, default: "€18,400")

  attr(:approvers, :list,
    default: @approvers,
    doc: "Up to three `%{name, role, status}`. Status is `approved`, `waiting` or `rejected`."
  )

  attr(:outcome, :string,
    default: nil,
    doc: "The footer line. Left empty, it is written from the approver statuses."
  )

  attr(:label, :string, default: "An approval request signed off by three people")
  frame_attrs()

  @spec process_approval(map()) :: Phoenix.LiveView.Rendered.t()
  def process_approval(assigns) do
    rows = assigns.approvers |> Enum.take(3) |> Enum.with_index()
    statuses = Enum.map(rows, fn {row, _index} -> row.status end)
    footer_y = 104 + length(rows) * 38 + 8

    assigns =
      assigns
      |> assign(:rows, rows)
      |> assign(:tone, tone(statuses))
      |> assign(:footer, assigns.outcome || outcome(statuses))
      |> assign(:footer_y, footer_y)
      |> assign(:height, footer_y + 30 + 20)

    ~H"""
    <.frame name="process/approval" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 340 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="339" height={@height - 1} rx="12" />
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="20" y="31" font-size="9">
            {String.upcase(@eyebrow)}
          </text>
          <text class="ehv-t-strong ehv-a-fade ehv-i-2" x="20" y="56" font-size="15">{@title}</text>
          <text class="ehv-t-3 ehv-a-fade ehv-i-2" x="20" y="76" font-size="11.5">{@meta}</text>
          <text
            class="ehv-t-display ehv-a-fade ehv-i-2"
            x="320"
            y="57"
            font-size="19"
            text-anchor="end"
          >
            {@amount}
          </text>
          <line class="ehv-line-faint" x1="20" x2="320" y1="94.5" y2="94.5" />
          <g :for={{row, index} <- @rows} class={"ehv-a-rise ehv-i-#{index + 3}"}>
            <circle class="ehv-card-3" cx="32" cy={123 + index * 38} r="12" />
            <text
              class="ehv-mono ehv-t-2"
              x="32"
              y={126 + index * 38}
              font-size="8"
              text-anchor="middle"
            >
              {initials(row.name)}
            </text>
            <text x="54" y={121 + index * 38} font-size="12.5">{row.name}</text>
            <text class="ehv-mono" x="54" y={134 + index * 38} font-size="8">
              {String.upcase(row.role)}
            </text>
            <g :if={row.status == "approved"}>
              <circle class="ehv-fill-lime-soft" cx="308" cy={123 + index * 38} r="10" />
              <path
                class={"ehv-line-lime ehv-round ehv-a-draw ehv-i-#{index + 6}"}
                d={"M303.5 #{123 + index * 38} l3.2 3.2 l5.6 -6.4"}
                pathLength="1"
              />
            </g>
            <g :if={row.status == "rejected"}>
              <circle class="ehv-fill-coral-soft" cx="308" cy={123 + index * 38} r="10" />
              <path
                class="ehv-line-coral ehv-round"
                d={"M304 #{119 + index * 38} l8 8 m0 -8 l-8 8"}
              />
            </g>
            <g :if={row.status not in ["approved", "rejected"]}>
              <circle class="ehv-line ehv-dash" cx="308" cy={123 + index * 38} r="9.5" />
              <circle class="ehv-fill-3 ehv-a-pulse" cx="308" cy={123 + index * 38} r="2.5" />
            </g>
          </g>
          <g class="ehv-a-rise ehv-i-8">
            <rect
              class={"ehv-process-approval__outcome--#{@tone}"}
              x="20"
              y={@footer_y}
              width="300"
              height="30"
              rx="6"
            />
            <text
              class={["ehv-mono", "ehv-process-approval__outcome-text--#{@tone}"]}
              x="170"
              y={@footer_y + 18.5}
              font-size="9"
              text-anchor="middle"
            >
              {String.upcase(@footer)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp initials(name) do
    name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
  end

  defp tone(statuses) do
    cond do
      "rejected" in statuses -> "stop"
      Enum.all?(statuses, &(&1 == "approved")) -> "go"
      true -> "wait"
    end
  end

  defp outcome(statuses) do
    case tone(statuses) do
      "go" -> "Approved · posted to the ERP"
      "stop" -> "Sent back with a question"
      "wait" -> "Waiting for #{Enum.count(statuses, &(&1 != "approved"))} of #{length(statuses)}"
    end
  end
end
assets/css/process/approval.css
approval.css
/* process/approval */
.ehv-process-approval { --ehv-max-w: 360px; }

.ehv-process-approval__outcome--go { fill: var(--ehv-lime-fill); }
.ehv-process-approval__outcome--wait { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-process-approval__outcome--stop { fill: var(--ehv-coral-soft); stroke: var(--ehv-coral); stroke-width: 1; }

.ehv svg .ehv-mono.ehv-process-approval__outcome-text--go { fill: var(--ehv-on-lime); }
.ehv svg .ehv-mono.ehv-process-approval__outcome-text--wait { fill: var(--ehv-ink-2); }
.ehv svg .ehv-mono.ehv-process-approval__outcome-text--stop { fill: var(--ehv-coral); }