Own it · example handover · app/

src/ui/rebuilt.tsx

← All files · the demo this builds

/**
 * The "after": a small job-tracking app over the same rows. Same job
 * numbers, same names, same dollars as the workbook — visibly aligned on
 * the wipe. Each wound has its resolution here: real columns instead of
 * merged headers, computed totals instead of typed ones, one date format,
 * Mike's orphaned jobs surfaced with a recovered badge, statuses
 * normalized, PO a first-class field.
 *
 * Edits are client-side state that resets on reload — enough to prove
 * it's an app, not a screenshot.
 */

import { useState } from "preact/hooks";
import { fmtUSD, toISO, type CleanStatus, type GsmData, type GsmJob } from "@portfolio/fabricator";

export type AppView = "jobs" | "schedule";
export type AppFocus = "total" | "recovered" | "dates" | "po" | null;

const STATUSES: CleanStatus[] = ["Scheduled", "In progress", "Done", "Invoiced", "On hold"];

function shortDate(d: Date): string {
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}

export function Rebuilt({ data, view, onView, focus }: {
  data: GsmData;
  view: AppView;
  onView: (v: AppView) => void;
  focus: AppFocus;
}) {
  const [open, setOpen] = useState<number | null>(null);
  const [statusEdits, setStatusEdits] = useState<ReadonlyMap<number, CleanStatus>>(new Map());
  // the total's provenance receipt (spec Half B: "where each number came
  // from" on totals) — the old workbook's Total row was typed and wrong,
  // so this one shows its arithmetic on demand
  const [whence, setWhence] = useState(false);

  const jobs: GsmJob[] = [...data.jobs, ...data.mikesJobs].sort(
    (a, b) => b.start.getTime() - a.start.getTime() || a.jobNo - b.jobNo,
  );
  const statusOf = (j: GsmJob) => statusEdits.get(j.jobNo) ?? j.status;
  const active = jobs.filter((j) => !j.cancelled);
  const totalQuotedC = active.reduce((s, j) => s + j.quotedC, 0);
  const cancelledCount = jobs.length - active.length;
  const recoveredCount = active.filter((j) => j.fromMikesCopy).length;
  const biggest = [...active].sort((a, b) => b.quotedC - a.quotedC).slice(0, 3);
  const restC = totalQuotedC - biggest.reduce((s, j) => s + j.quotedC, 0);

  const days = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;

  return (
    <div class="ssr-app">
      <header class="ssr-app-head">
        <p class="ssr-app-brand">{data.company} · job board</p>
        <nav class="ssr-app-tabs" aria-label="App views">
          <button type="button" class={view === "jobs" ? "is-on" : ""} onClick={() => onView("jobs")}>Jobs</button>
          <button type="button" class={view === "schedule" ? "is-on" : ""} onClick={() => onView("schedule")}>
            Schedule · wk of {shortDate(data.weekOf)}
          </button>
        </nav>
      </header>

      {view === "jobs" ? (
        <>
          <ul class="ssr-jobs">
            {jobs.map((j) => (
              <li key={j.jobNo} class={`ssr-jobrow ${j.fromMikesCopy && focus === "recovered" ? "is-focus" : ""}`}>
                <button type="button" class="ssr-jobbtn" aria-expanded={open === j.jobNo} onClick={() => setOpen(open === j.jobNo ? null : j.jobNo)}>
                  <span class="ssr-jobno">{j.jobNo}</span>
                  <span class="ssr-jobmain">
                    <span class="ssr-jobcust">{j.customer} <em>({j.town})</em></span>
                    <span class="ssr-jobdesc">{j.desc}</span>
                    {j.fromMikesCopy && <span class="ssr-recovered">recovered from a hidden tab</span>}
                    {j.cancelled && <span class="ssr-cancelled">cancelled — kept for the record</span>}
                  </span>
                  <span class="ssr-jobside">
                    <span class={`ssr-status s-${statusOf(j).replace(" ", "")}`}>{statusOf(j)}</span>
                    <span class={`ssr-jobdate ${focus === "dates" ? "is-focus" : ""}`}>{shortDate(j.start)}</span>
                  </span>
                </button>
                {open === j.jobNo && (
                  <dl class="ssr-detail">
                    <div><dt>Quoted</dt><dd>{fmtUSD(j.quotedC)}</dd></div>
                    <div><dt>Invoiced</dt><dd>{j.invoicedC > 0 ? fmtUSD(j.invoicedC) : "—"}</dd></div>
                    <div><dt>Balance</dt><dd>{fmtUSD(j.quotedC - j.invoicedC)} <small>(computed — it can't dangle)</small></dd></div>
                    <div><dt>Crew</dt><dd>{j.crew}</dd></div>
                    <div><dt>Start</dt><dd>{toISO(j.start)}</dd></div>
                    <div class={focus === "po" ? "is-focus" : ""}><dt>PO #</dt><dd>{j.po ?? "— none recorded"}</dd></div>
                    {j.notes && <div><dt>Notes</dt><dd>{j.notes}</dd></div>}
                    <div>
                      <dt><label for={`st-${j.jobNo}`}>Status</label></dt>
                      <dd>
                        <select
                          id={`st-${j.jobNo}`}
                          value={statusOf(j)}
                          onChange={(e) => {
                            const next = new Map(statusEdits);
                            next.set(j.jobNo, (e.target as HTMLSelectElement).value as CleanStatus);
                            setStatusEdits(next);
                          }}
                        >
                          {STATUSES.map((s) => <option value={s}>{s}</option>)}
                        </select>{" "}
                        <small>edits live here only — reloads reset this demo</small>
                      </dd>
                    </div>
                  </dl>
                )}
              </li>
            ))}
          </ul>
          <p class={`ssr-total ${focus === "total" ? "is-focus" : ""}`}>
            Quoted this year: <strong>{fmtUSD(totalQuotedC)}</strong>{" "}
            <small>computed from {active.length} jobs just now — nothing typed in, nothing stale</small>{" "}
            <button type="button" class="ssr-whence" aria-expanded={whence} onClick={() => setWhence(!whence)}>
              {whence ? "hide the arithmetic" : "where's that from?"}
            </button>
          </p>
          {whence && (
            <div class="ssr-whence-panel">
              <p>The Quoted column of the list above, added up the moment you opened this:</p>
              <table>
                <tbody>
                  {biggest.map((j) => (
                    <tr><td>#{j.jobNo} {j.customer}</td><td>{fmtUSD(j.quotedC)}</td></tr>
                  ))}
                  <tr><td>…the other {active.length - biggest.length} jobs</td><td>{fmtUSD(restC)}</td></tr>
                  <tr class="ssr-whence-sum"><td>= quoted this year</td><td>{fmtUSD(totalQuotedC)}</td></tr>
                </tbody>
              </table>
              <p>
                Includes the {recoveredCount} jobs recovered from the hidden tab; leaves out{" "}
                {cancelledCount === 1 ? "the 1 cancelled job" : `the ${cancelledCount} cancelled jobs`} kept
                for the record. There is no cell where a person could type a different answer — that's the
                difference from the old Total row, which had been wrong since spring.
              </p>
            </div>
          )}
        </>
      ) : (
        <table class="ssr-sched">
          <thead>
            <tr><th scope="col">Crew</th>{days.map((d) => <th scope="col">{d}</th>)}</tr>
          </thead>
          <tbody>
            {(["A", "B", "C"] as const).map((crew) => (
              <tr>
                <th scope="row">{crew}</th>
                {days.map((_, day) => {
                  const slot = data.schedule.find((s) => s.crew === crew && s.day === day);
                  return <td>{slot ? slot.label : ""}{slot?.confirmed && <span class="ssr-confirmed"> ✓ confirmed</span>}</td>;
                })}
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
}