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;
That approach failed for two reasons:
- Data drift – The percentages quickly became inaccurate as new device types were added.
-
Type safety – The
countsobject was typed to only containtotal, so TypeScript threw errors when I tried to referencepcCount,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
};
}
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";
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";
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>
);
}
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>
);
}
I also updated the import list at the top of the file:
import { DesktopComputer, Monitor, Tv } from "lucide-react";
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
}
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
}
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:
- Adding
startDate/endDateparameters togetDeviceCounts. - Updating the Prisma queries with
whereclauses. - 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)