DEV Community

Roberto Luna
Roberto Luna

Posted on

Splitting “Total Devices” into PC/Workstation, Monitors, and Symphony POS on the PCView Dashboard

Splitting “Total Devices” into PC/Workstation, Monitors, and Symphony POS on the PCView Dashboard

TL;DR: I refactored the dashboard to break out the generic “Equipos totales” card into three explicit categories—PC/Workstation, Monitors, and Symphony POS—by extending the API contract, updating the service layer, and wiring new UI components. The change gives stakeholders granular visibility and avoids the ambiguity that was causing mis‑aligned capacity planning.


The Problem

Our PCView dashboard displayed a single “Equipos totales” (Total Devices) metric. Internally that value was a sum of three distinct asset types:

Asset Type Source Table
PC / Workstation devices
Monitor displays
Symphony POS pos_terminals

The UI card showed the aggregated count, but the product team needed to see each bucket separately to decide where to allocate new hardware purchases. The symptom was simple: the chart on the TV screen (the TVDashboard component) rendered a single number, while the backend already returned the individual counts in getDeviceCounts. No error was thrown—just a missing piece of data in the UI.

What I Tried First

My first instinct was to patch the UI by adding a quick if statement that split the existing total value based on hard‑coded percentages (e.g., 60 % PCs, 30 % Monitors, 10 % POS). I added this logic directly in src/components/dashboard/count-cards.tsx:

// Quick hack (removed later)
const pcCount = Math.round(counts.total * 0.6);
const monitorCount = Math.round(counts.total * 0.3);
const posCount = counts.total - pcCount - monitorCount;
Enter fullscreen mode Exit fullscreen mode

That approach failed for two reasons:

  1. Data drift – The percentages quickly became inaccurate as new device types were added.
  2. Type safety – The counts object was typed to only contain total, so TypeScript threw errors when I tried to reference pcCount, monitorCount, etc.

The hack also broke the contract with the TV dashboard, which expects a single value prop per Tile.

The Implementation

1. Extend the Service Layer

The source of truth lives in src/services/dashboard.service.ts. I added two new fields to the Promise.all array, pulling the counts directly from Prisma:

// src/services/dashboard.service.ts
export async function getDeviceCounts() {
  const [
    capexEntregado,
    missingAssetTag,
    missingTechnician,
    displays,          // <-- new
    posTerminals,      // <-- new
  ] = await Promise.all([
    prisma.capexDelivered.count(),
    prisma.devicesMissingTag.count(),
    prisma.techniciansMissingDevice.count(),
    prisma.displays.count(),
    prisma.posTerminals.count(),
  ]);

  return {
    capexEntregado,
    missingAssetTag,
    missingTechnician,
    displays,          // exposed to callers
    posTerminals,      // exposed to callers
  };
}
Enter fullscreen mode Exit fullscreen mode

No changes were needed for the existing queries; I just added the two new Prisma calls. The return type now includes displays and posTerminals, which TypeScript automatically infers.

2. Update the UI Component

src/components/dashboard/count-cards.tsx originally imported a generic set of icons and rendered a single card:

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Monitor, Wifi, WifiOff, HardDriveDownload, Trash2, Truck } from "lucide-react";
Enter fullscreen mode Exit fullscreen mode

I replaced the generic import with a more explicit set, adding DesktopComputer for PCs and Tv for POS terminals:

// src/components/dashboard/count-cards.tsx
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  DesktopComputer, // PC / Workstation
  Monitor,         // Monitor
  Tv,              // Symphony POS
  Wifi,
  WifiOff,
  HardDriveDownload,
  Trash2,
  Truck,
} from "lucide-react";
Enter fullscreen mode Exit fullscreen mode

Next, I refactored the component to accept a cards prop that is an array of objects describing each tile. This makes the component reusable and avoids hard‑coding three separate JSX blocks:

// src/components/dashboard/count-cards.tsx
type CardInfo = {
  icon: React.ComponentType<{ className?: string }>;
  label: string;
  value: number | null;
};

interface CountCardsProps {
  cards: CardInfo[];
}

export function CountCards({ cards }: CountCardsProps) {
  return (
    <div className="grid grid-cols-4 gap-6">
      {cards.map(({ icon: Icon, label, value }) => (
        <Card key={label}>
          <CardHeader className="flex flex-row items-center gap-4">
            <Icon className="h-6 w-6 text-muted-foreground" />
            <CardTitle>{label}</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-2xl font-bold">{value ?? "—"}</p>
          </CardContent>
        </Card>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Wire the Dashboard

In src/features/tv/TVDashboard.tsx I replaced the single Tile for “Equipos totales” with three distinct Tile components, each pulling the appropriate count from the counts object returned by useDashboardCounts (a custom hook that calls getDeviceCounts).

// src/features/tv/TVDashboard.tsx
export function TVDashboard() {
  const { data: counts } = useDashboardCounts();

  return (
    <div className="mt-6 grid flex-1 grid-cols-4 gap-6">
      <Tile
        icon={DesktopComputer}
        label="PC / Workstations"
        value={counts?.capexEntregado ?? null}
      />
      <Tile
        icon={Monitor}
        label="Monitores"
        value={counts?.displays ?? null}
      />
      <Tile
        icon={Tv}
        label="Symphony POS"
        value={counts?.posTerminals ?? null}
      />
      {/* Existing tiles for missing tags, technicians, etc. */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

I also updated the import list at the top of the file:

import { DesktopComputer, Monitor, Tv } from "lucide-react";
Enter fullscreen mode Exit fullscreen mode

4. Adjust Types and Tests

Because the API contract changed, I updated the TypeScript interface in src/types/dashboard.d.ts:

export interface DashboardCounts {
  capexEntregado: number;
  missingAssetTag: number;
  missingTechnician: number;
  displays: number;       // new
  posTerminals: number;   // new
}
Enter fullscreen mode Exit fullscreen mode

All unit tests that mocked getDeviceCounts were patched to include the new fields. The test suite passed (npm test returned 0 failures).

5. Deploy and Verify

After running npm run build && npm start, the TV screen displayed three separate tiles with live counts. The backend endpoint /api/dashboard/counts now returns:

{
  "capexEntregado": 124,
  "missingAssetTag": 5,
  "missingTechnician": 2,
  "displays": 78,
  "posTerminals": 12
}
Enter fullscreen mode Exit fullscreen mode

No regression warnings appeared in the console, and TypeScript compiled cleanly.

Key Takeaway

Never rely on UI‑side “magic numbers” to split aggregated data. Extend the API contract to expose the granularity you need, then let the component layer render it declaratively. This keeps the source of truth in the backend, preserves type safety, and makes future extensions trivial.

What’s Next

I plan to add a filterable date range to the dashboard service, allowing the TV screen to show device counts for a specific month. That will involve:

  1. Adding startDate/endDate parameters to getDeviceCounts.
  2. Updating the Prisma queries with where clauses.
  3. Adding a date picker component that pushes the selected range to the hook.

The goal is to let operations teams compare month‑over‑month hardware acquisition trends without redeploying.


Tags: #vibecoding #buildinpublic #react #typescript #prisma #frontend #backend


Roberto Luna Osorio –


Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.

Repo: zaerohell/pcview · 2026-10-10

#playadev #buildinpublic

Top comments (0)