feat: enhance UI components and add assignment feature to deliverables

- Updated CommandItem component to use rounded-lg for better aesthetics.
- Modified DialogOverlay and DialogContent to improve backdrop and border radius.
- Changed DropdownMenuItem, DropdownMenuCheckboxItem, and DropdownMenuRadioItem to use rounded-md for consistency.
- Enhanced SelectItem with rounded-md for a more modern look.
- Updated SheetOverlay to improve backdrop styling.
- Adjusted Toaster component border radius for a more refined appearance.
- Enhanced Table component with rounded-xl and shadow for better visual hierarchy.
- Added assignment display feature in DeliverableTable and KanbanBoard components, showing assigned users with badges.
- Updated deliverable service to include assignments in the data fetching process.
- Created a new seed script for tracker data to facilitate testing and development.
This commit is contained in:
Leivur Djurhuus 2026-03-02 13:46:55 -06:00
parent a47c6791d9
commit edcf31672e
26 changed files with 951 additions and 109 deletions

View file

@ -13,7 +13,8 @@
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "prisma db seed",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"db:seed-tracker": "tsx prisma/seed-tracker-data.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",

695
prisma/seed-tracker-data.ts Normal file
View file

@ -0,0 +1,695 @@
import "dotenv/config";
import * as XLSX from "xlsx";
import * as path from "path";
import * as fs from "fs";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });
// ─── Type → Pipeline Stage Slug Mapping ──────────────────
const TYPE_TO_STAGE_SLUG: Record<string, string> = {
catalog: "catalog-images",
"catalog ": "catalog-images",
"early catalog images": "early-images",
"early catalog": "early-images",
hero: "hero-images",
"hero image": "hero-images",
"hero imagery": "hero-images",
"hero ": "hero-images",
"hero images": "hero-images",
packaging: "packaging-images",
pkg: "packaging-images",
"pkg ": "packaging-images",
photocomp: "photocomps",
"photocomp ": "photocomps",
lifestyle: "photocomps",
"lifestyle ": "photocomps",
"360 spin": "360-spin-animations",
"annotated spin": "360-spin-animations",
"360 annotated": "360-spin-animations",
"360 annotated ": "360-spin-animations",
"simple spin": "360-spin-animations",
"dynamic spin": "dynamic-spin",
animations: "dynamic-spin",
"animations ": "dynamic-spin",
"annotated + dynamic": "dynamic-spin",
"product video": "dynamic-spin",
"product video ": "dynamic-spin",
refresh: "catalog-images",
"screen swap": "catalog-images",
exploded: "hero-images",
"cad update": "model-prep",
};
// ─── Status Mapping ──────────────────────────────────────
function mapTeamStatus(raw: string | undefined): {
deliverableStatus: string;
isDelivered: boolean;
} {
if (!raw) return { deliverableStatus: "IN_PROGRESS", isDelivered: false };
const s = raw.toString().trim().toLowerCase();
if (
s.includes("delivered") ||
s.includes("dam") ||
s === "dam done" ||
s === "dam initiated"
)
return { deliverableStatus: "APPROVED", isDelivered: true };
if (s.includes("cancel"))
return { deliverableStatus: "ON_HOLD", isDelivered: false };
if (s.includes("hold") || s === "on hold")
return { deliverableStatus: "ON_HOLD", isDelivered: false };
if (
s.includes("wip") ||
s.includes("in progress") ||
s.includes("clays") ||
s.includes("lighting") ||
s.includes("started") ||
s.includes("prep")
)
return { deliverableStatus: "IN_PROGRESS", isDelivered: false };
if (
s.includes("review") ||
s.includes("pending") ||
s.includes("under review")
)
return { deliverableStatus: "IN_REVIEW", isDelivered: false };
if (s.includes("tba") || s.includes("tbs") || s.includes("tbi"))
return { deliverableStatus: "NOT_STARTED", isDelivered: false };
if (s === "to be started" || s === "brief pending")
return { deliverableStatus: "NOT_STARTED", isDelivered: false };
return { deliverableStatus: "IN_PROGRESS", isDelivered: false };
}
function mapQ2Status(raw: string | undefined): {
deliverableStatus: string;
isDelivered: boolean;
} {
if (!raw) return { deliverableStatus: "IN_PROGRESS", isDelivered: false };
const s = raw.toString().trim().toLowerCase();
if (s.includes("delivered"))
return { deliverableStatus: "APPROVED", isDelivered: true };
if (s.includes("hold"))
return { deliverableStatus: "ON_HOLD", isDelivered: false };
if (
s.includes("in progress") ||
s.includes("wip") ||
s.includes("clays") ||
s.includes("lighting") ||
s.includes("under prep")
)
return { deliverableStatus: "IN_PROGRESS", isDelivered: false };
if (s.includes("model prep"))
return { deliverableStatus: "IN_PROGRESS", isDelivered: false };
if (
s.includes("to be started") ||
s.includes("tba") ||
s.includes("tbi") ||
s.includes("brief pending") ||
s.includes("share quote") ||
s.includes("quotes")
)
return { deliverableStatus: "NOT_STARTED", isDelivered: false };
if (s.includes("aug due"))
return { deliverableStatus: "NOT_STARTED", isDelivered: false };
return { deliverableStatus: "IN_PROGRESS", isDelivered: false };
}
// ─── Parse Date ──────────────────────────────────────────
function parseDate(raw: any): Date | null {
if (!raw) return null;
if (raw instanceof Date) return raw;
const s = raw.toString().trim();
if (!s || s === "TBD" || s === "TBA" || s === "N.R") return null;
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
// ─── Parse Cost ──────────────────────────────────────────
function parseCost(raw: any): number | null {
if (raw == null) return null;
const s = raw.toString().replace(/[$,\s]/g, "");
const match = s.match(/^[\d.]+/);
if (!match) return null;
const n = parseFloat(match[0]);
return isNaN(n) ? null : n;
}
// ─── Row Interface ───────────────────────────────────────
interface TrackerRow {
quarter: string;
wfName: string;
bu: string;
formFactor: string;
codeName: string;
type: string;
cmfSku: string;
assetCount: number | null;
dueDate: Date | null;
remark: string;
artist: string;
teamStatus: string;
requestor: string;
producer: string;
omgCode: string;
cost: number | null;
bmtId: string;
deliverableStatus: string;
isDelivered: boolean;
wfInputDate: Date | null;
requestedDueDate: Date | null;
deliveryDate: Date | null;
}
// ─── Project Group ───────────────────────────────────────
interface ProjectGroup {
wfName: string;
bu: string;
formFactor: string;
codeName: string;
quarter: string;
requestor: string;
producer: string;
omgCode: string;
bmtId: string;
totalCost: number;
dueDate: Date | null;
isCompleted: boolean;
rows: TrackerRow[];
}
// ─── Sheet Parsers ───────────────────────────────────────
function parseQ4WIP(workbook: XLSX.WorkBook): TrackerRow[] {
const sheet = workbook.Sheets["Q4_WIP_Projects"];
if (!sheet) return [];
const json = XLSX.utils.sheet_to_json<any>(sheet);
const rows: TrackerRow[] = [];
for (const r of json) {
const wf = r["WF"]?.toString().trim();
if (!wf) continue;
const { deliverableStatus, isDelivered } = mapTeamStatus(r["Team Status"]);
rows.push({
quarter: r["Qtr"]?.toString() || "Q4",
wfName: wf,
bu: r["BU"]?.toString().trim() || "",
formFactor: r["Form Factor"]?.toString().trim() || "",
codeName: r["Code Names"]?.toString().trim() || "",
type: r["Type"]?.toString().trim() || "Catalog",
cmfSku: r["CMF/Sku"]?.toString().trim() || "",
assetCount: r["#Asset"] ? parseInt(r["#Asset"]) || null : null,
dueDate: parseDate(r["Due Date"]),
remark: r["Remark"]?.toString().trim() || "",
artist: r["Artist"]?.toString().trim() || "",
teamStatus: r["Team Status"]?.toString().trim() || "",
requestor: r["Requestor"]?.toString().trim() || "",
producer: r["Producer"]?.toString().trim() || "",
omgCode: r["OMG Code"]?.toString().trim() || "",
cost: parseCost(r["Costs "]),
bmtId: r["BMT ID"]?.toString().trim() || "",
deliverableStatus,
isDelivered,
wfInputDate: null,
requestedDueDate: null,
deliveryDate: null,
});
}
return rows;
}
function parseQ2_2026(workbook: XLSX.WorkBook): TrackerRow[] {
const sheet = workbook.Sheets["Q2_2026"];
if (!sheet) return [];
const json = XLSX.utils.sheet_to_json<any>(sheet);
const rows: TrackerRow[] = [];
for (const r of json) {
const wf = r["WF"]?.toString().trim();
if (!wf) continue;
const { deliverableStatus, isDelivered } = mapQ2Status(r[" Status"]);
rows.push({
quarter: r["Qtr"]?.toString() || "FY26Q2",
wfName: wf,
bu: r["BU"]?.toString().trim() || "",
formFactor: r["Form Factor"]?.toString().trim() || "",
codeName: r["Code Names"]?.toString().trim() || "",
type: r["Type"]?.toString().trim() || "Catalog",
cmfSku: r["CMF/Sku"]?.toString().trim() || "",
assetCount: r["#Asset"] ? parseInt(r["#Asset"]) || null : null,
dueDate: parseDate(r["Delivery Date"]) || parseDate(r["Requested Due Date"]),
remark: r["Remark"]?.toString().trim() || "",
artist: r["Artist"]?.toString().trim() || "",
teamStatus: r[" Status"]?.toString().trim() || "",
requestor: r["Requestor"]?.toString().trim() || "",
producer: r["Producer"]?.toString().trim() || "",
omgCode: r["OMG Code"]?.toString().trim() || "",
cost: parseCost(r["Costs "]),
bmtId: r["BMT ID"]?.toString().trim() || "",
deliverableStatus,
isDelivered,
wfInputDate: parseDate(r["WF Input Date"]),
requestedDueDate: parseDate(r["Requested Due Date"]),
deliveryDate: parseDate(r["Delivery Date"]),
});
}
return rows;
}
function parseDelivered(workbook: XLSX.WorkBook): TrackerRow[] {
const sheet = workbook.Sheets["Qs_Delivered_Q3_Delivered_Tab"];
if (!sheet) return [];
const json = XLSX.utils.sheet_to_json<any>(sheet);
const rows: TrackerRow[] = [];
for (const r of json) {
const wf = r["WF"]?.toString().trim();
if (!wf) continue;
rows.push({
quarter: r["Qtr"]?.toString() || "Q3",
wfName: wf,
bu: r["BU"]?.toString().trim() || "",
formFactor: r["Form Factor"]?.toString().trim() || "",
codeName: r["Code Names"]?.toString().trim() || "",
type: r["Type"]?.toString().trim() || "Catalog",
cmfSku: r["CMF/Sku"]?.toString().trim() || "",
assetCount: r["#Asset"] ? parseInt(r["#Asset"]) || null : null,
dueDate: parseDate(r["Due Date"]),
remark: r["Remark"]?.toString().trim() || "",
artist: r["Artist"]?.toString().trim() || "",
teamStatus: r["Team Status"]?.toString().trim() || "Delivered",
requestor: r["Requestor"]?.toString().trim() || "",
producer: r["Producer"]?.toString().trim() || "",
omgCode: r["OMG Code"]?.toString().trim() || "",
cost: parseCost(r["Costs "]),
bmtId: r["BMT ID"]?.toString().trim() || "",
deliverableStatus: "APPROVED",
isDelivered: true,
wfInputDate: null,
requestedDueDate: null,
deliveryDate: parseDate(r["Due Date"]),
});
}
return rows;
}
function parseAllSpins(workbook: XLSX.WorkBook): TrackerRow[] {
const sheet = workbook.Sheets["All_Spins Project"];
if (!sheet) return [];
const json = XLSX.utils.sheet_to_json<any>(sheet);
const rows: TrackerRow[] = [];
for (const r of json) {
const wf = r["WF/OMG"]?.toString().trim();
if (!wf) continue;
const status = r["Status"]?.toString().trim() || "";
const isDelivered = status.toLowerCase().includes("delivered");
rows.push({
quarter: r["Qtr"]?.toString() || "",
wfName: wf,
bu: r["BU"]?.toString().trim() || "",
formFactor: r["Form Factor"]?.toString().trim() || "",
codeName: r["Code Names"]?.toString().trim() || "",
type: r["Type"]?.toString().trim() || "360 Spin",
cmfSku: r["CMF"]?.toString().trim() || "",
assetCount: r["#Assets"] ? parseInt(r["#Assets"]) || null : null,
dueDate: parseDate(r["Req Due Date"]) || parseDate(r["Planned Delivery Date"]),
remark: r["Remarks"]?.toString().trim() || "",
artist: r["Artist"]?.toString().trim() || "",
teamStatus: status,
requestor: r["Requestor"]?.toString().trim() || "",
producer: r["Producer"]?.toString().trim() || "",
omgCode: r["OMG Code"]?.toString().trim() || "",
cost: parseCost(r["Amoumt"]),
bmtId: r["BMT"]?.toString().trim() || "",
deliverableStatus: isDelivered ? "APPROVED" : "IN_PROGRESS",
isDelivered,
wfInputDate: parseDate(r["WF Input Date"]),
requestedDueDate: parseDate(r["Req Due Date"]),
deliveryDate: parseDate(r["Planned Delivery Date"]),
});
}
return rows;
}
// ─── Group Rows into Projects ────────────────────────────
function groupIntoProjects(rows: TrackerRow[]): ProjectGroup[] {
const map = new Map<string, ProjectGroup>();
for (const row of rows) {
// Use WF name as the project key
const key = row.wfName.toLowerCase().replace(/\s+/g, " ").trim();
if (!key) continue;
const existing = map.get(key);
if (existing) {
existing.rows.push(row);
if (row.cost) existing.totalCost += row.cost;
if (!existing.dueDate && row.dueDate) existing.dueDate = row.dueDate;
if (row.dueDate && existing.dueDate && row.dueDate > existing.dueDate) {
existing.dueDate = row.dueDate;
}
// Project is only completed if ALL rows are delivered
if (!row.isDelivered) existing.isCompleted = false;
} else {
map.set(key, {
wfName: row.wfName,
bu: row.bu,
formFactor: row.formFactor,
codeName: row.codeName,
quarter: row.quarter,
requestor: row.requestor,
producer: row.producer,
omgCode: row.omgCode,
bmtId: row.bmtId,
totalCost: row.cost || 0,
dueDate: row.dueDate,
isCompleted: row.isDelivered,
rows: [row],
});
}
}
return Array.from(map.values());
}
// ─── Generate Project Code ───────────────────────────────
let projectCounter = 0;
function generateProjectCode(group: ProjectGroup): string {
projectCounter++;
const buCode = group.bu
? group.bu.replace(/[^A-Za-z]/g, "").substring(0, 3).toUpperCase()
: "HP";
const qtr = group.quarter.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
return `${buCode}-${qtr}-${String(projectCounter).padStart(3, "0")}`;
}
// ─── Determine Pipeline Stage Statuses ───────────────────
function getStageStatuses(
row: TrackerRow,
stageTemplates: { id: string; slug: string; order: number }[]
): Map<string, string> {
const statuses = new Map<string, string>();
const typeLower = (row.type || "catalog").toLowerCase().trim();
const activeSlug = TYPE_TO_STAGE_SLUG[typeLower] || "catalog-images";
// Find the order of the active stage
const activeTemplate = stageTemplates.find((t) => t.slug === activeSlug);
const activeOrder = activeTemplate?.order || 5;
for (const template of stageTemplates) {
if (row.isDelivered) {
// For delivered items: all stages up to and including the active one are DELIVERED
if (template.order <= activeOrder) {
statuses.set(template.slug, "DELIVERED");
} else {
statuses.set(template.slug, "SKIPPED");
}
} else if (row.deliverableStatus === "NOT_STARTED") {
if (template.order === 1) {
statuses.set(template.slug, "NOT_STARTED");
} else {
statuses.set(template.slug, "BLOCKED");
}
} else if (row.deliverableStatus === "ON_HOLD") {
if (template.order <= 2) {
statuses.set(template.slug, "APPROVED");
} else {
statuses.set(template.slug, "BLOCKED");
}
} else {
// IN_PROGRESS or IN_REVIEW
if (template.order < activeOrder - 1) {
statuses.set(template.slug, "DELIVERED");
} else if (template.order === activeOrder - 1) {
statuses.set(template.slug, "APPROVED");
} else if (template.order === activeOrder) {
if (row.deliverableStatus === "IN_REVIEW") {
statuses.set(template.slug, "IN_REVIEW");
} else {
statuses.set(template.slug, "IN_PROGRESS");
}
} else {
statuses.set(template.slug, "BLOCKED");
}
}
}
return statuses;
}
// ─── Main ────────────────────────────────────────────────
async function main() {
const excelPath = path.resolve(
__dirname,
"../assets/temp/Master_CG Tracker (1).xlsx"
);
if (!fs.existsSync(excelPath)) {
console.error(`Excel file not found: ${excelPath}`);
process.exit(1);
}
console.log("Reading Excel tracker...");
const workbook = XLSX.readFile(excelPath, { cellDates: true });
// Parse all sheets
const q4Rows = parseQ4WIP(workbook);
const q2Rows = parseQ2_2026(workbook);
const deliveredRows = parseDelivered(workbook);
const spinRows = parseAllSpins(workbook);
console.log(` Q4 WIP: ${q4Rows.length} rows`);
console.log(` Q2 2026: ${q2Rows.length} rows`);
console.log(` Delivered: ${deliveredRows.length} rows`);
console.log(` Spins: ${spinRows.length} rows`);
const allRows = [...q2Rows, ...q4Rows, ...deliveredRows, ...spinRows];
const projects = groupIntoProjects(allRows);
console.log(`\nGrouped into ${projects.length} projects`);
// Get pipeline stage templates
const templates = await prisma.pipelineStageTemplate.findMany({
include: { dependsOn: true },
orderBy: { order: "asc" },
});
if (templates.length === 0) {
console.error(
"No pipeline stage templates found. Run the base seed first (prisma db seed)."
);
process.exit(1);
}
console.log(`Found ${templates.length} pipeline stage templates`);
// Get the dev organization
const org = await prisma.organization.findFirst({
where: { id: "dev-org-001" },
});
if (!org) {
console.error(
"Dev organization not found. Run the base seed first (prisma db seed)."
);
process.exit(1);
}
// Clear existing projects (to allow re-runs)
const existingCount = await prisma.project.count({
where: { organizationId: org.id },
});
if (existingCount > 0) {
console.log(`\nClearing ${existingCount} existing projects...`);
await prisma.project.deleteMany({ where: { organizationId: org.id } });
}
// Create projects and deliverables
let totalDeliverables = 0;
let totalStages = 0;
let createdProjects = 0;
for (const group of projects) {
// Skip projects with empty names or too-short names
if (group.wfName.length < 3) continue;
// Determine project status
const hasOnHold = group.rows.some(
(r) => r.deliverableStatus === "ON_HOLD"
);
const allDelivered = group.rows.every((r) => r.isDelivered);
const allNotStarted = group.rows.every(
(r) => r.deliverableStatus === "NOT_STARTED"
);
let projectStatus: string;
if (allDelivered) {
projectStatus = "COMPLETED";
} else if (hasOnHold && group.rows.every((r) => r.deliverableStatus === "ON_HOLD" || r.isDelivered)) {
projectStatus = "ON_HOLD";
} else {
projectStatus = "ACTIVE";
}
// Determine priority based on cost or due date proximity
let priority = "MEDIUM";
if (group.totalCost > 20000) priority = "HIGH";
else if (group.totalCost > 40000) priority = "URGENT";
else if (group.totalCost < 3000 && group.totalCost > 0) priority = "LOW";
const projectCode = generateProjectCode(group);
try {
const project = await prisma.project.create({
data: {
projectCode,
name: group.wfName,
status: projectStatus as any,
priority: priority as any,
businessUnit: group.bu || null,
formFactor: group.formFactor || null,
codeName: group.codeName || null,
quarter: group.quarter || null,
requestor: group.requestor || null,
omgCode: group.omgCode || null,
bmtId: group.bmtId || null,
estimatedCost: group.totalCost > 0 ? group.totalCost : null,
dueDate: group.dueDate,
organizationId: org.id,
},
});
// Create deliverables for each row in this project
for (const row of group.rows) {
const delivName = row.codeName
? `${row.codeName} - ${row.type || "Catalog"}`
: `${group.wfName} - ${row.type || "Catalog"}`;
const deliverable = await prisma.deliverable.create({
data: {
projectId: project.id,
name:
delivName.length > 200 ? delivName.substring(0, 200) : delivName,
status: row.deliverableStatus as any,
priority: priority as any,
dueDate: row.dueDate,
notes: row.remark || null,
cmfSku: row.cmfSku || null,
assetCount: row.assetCount,
requestedDueDate: row.requestedDueDate,
plannedDeliveryDate: row.deliveryDate,
actualDeliveryDate: row.isDelivered ? row.deliveryDate : null,
wfInputDate: row.wfInputDate,
},
});
// Create pipeline stages with appropriate statuses
const stageStatuses = getStageStatuses(row, templates as any);
const stageData = templates.map((template) => {
const status =
(stageStatuses.get(template.slug) as any) || "BLOCKED";
// Set dates based on status
let startDate: Date | null = null;
let completedDate: Date | null = null;
if (
status === "DELIVERED" ||
status === "APPROVED" ||
status === "SKIPPED"
) {
// For completed stages, set reasonable dates
startDate = row.dueDate
? new Date(
row.dueDate.getTime() -
(template.estimatedDays || 3) * 86400000 * 2
)
: null;
completedDate = row.dueDate
? new Date(
row.dueDate.getTime() -
(10 - template.order) * 86400000
)
: null;
} else if (status === "IN_PROGRESS" || status === "IN_REVIEW") {
startDate = new Date(
Date.now() - (template.estimatedDays || 2) * 86400000
);
}
return {
deliverableId: deliverable.id,
templateId: template.id,
status,
dueDate: row.dueDate,
startDate,
completedDate,
};
});
await prisma.deliverableStage.createMany({ data: stageData });
totalStages += stageData.length;
totalDeliverables++;
}
createdProjects++;
} catch (err: any) {
// Skip duplicates or other errors silently
if (!err.message?.includes("Unique constraint")) {
console.error(
` Error creating project "${group.wfName}": ${err.message}`
);
}
}
}
console.log(`\n=== Seed Complete ===`);
console.log(` Projects created: ${createdProjects}`);
console.log(` Deliverables created: ${totalDeliverables}`);
console.log(` Stages created: ${totalStages}`);
// Print summary by status
const statusCounts = await prisma.project.groupBy({
by: ["status"],
_count: true,
where: { organizationId: org.id },
});
console.log(`\n Project Status Breakdown:`);
for (const s of statusCounts) {
console.log(` ${s.status}: ${s._count}`);
}
const delivStatusCounts = await prisma.deliverable.groupBy({
by: ["status"],
_count: true,
});
console.log(`\n Deliverable Status Breakdown:`);
for (const s of delivStatusCounts) {
console.log(` ${s.status}: ${s._count}`);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -72,11 +72,11 @@ function KpiCard({
accent?: boolean;
}) {
return (
<Card>
<Card className="hover:shadow-[var(--shadow-md)]">
<CardContent className="flex items-center gap-4 py-4">
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl",
accent
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "bg-[var(--primary)]/10 text-[var(--primary)]"
@ -85,7 +85,7 @@ function KpiCard({
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-2xl font-bold tracking-tight">{value}</p>
<p className="text-xs text-[var(--muted-foreground)]">{title}</p>
{subtitle && (
<p className="text-[10px] text-[var(--muted-foreground)]">
@ -207,7 +207,7 @@ export default function DashboardPage() {
<RTooltip
contentStyle={{
fontSize: "12px",
borderRadius: "6px",
borderRadius: "10px",
}}
/>
</PieChart>
@ -246,7 +246,7 @@ export default function DashboardPage() {
<RTooltip
contentStyle={{
fontSize: "12px",
borderRadius: "6px",
borderRadius: "10px",
}}
/>
<Bar
@ -300,7 +300,7 @@ export default function DashboardPage() {
{overdueDeliverables.map((deliv: any) => (
<div
key={deliv.id}
className="flex items-center gap-3 rounded border border-[var(--status-blocked)]/20 px-3 py-2"
className="flex items-center gap-3 rounded-lg border border-[var(--status-blocked)]/20 px-3 py-2.5 transition-colors hover:bg-[var(--muted)]/50"
>
<div className="min-w-0 flex-1">
<Link

View file

@ -88,7 +88,7 @@ export default function NotificationsPage() {
<div
key={notif.id}
className={cn(
"flex items-start gap-3 rounded-md border px-4 py-3",
"flex items-start gap-3 rounded-xl border px-4 py-3 transition-colors",
!notif.isRead && "border-[var(--primary)]/20 bg-[var(--primary)]/5"
)}
>

View file

@ -157,7 +157,7 @@ export default function DeliverableDetailPage() {
deliverable.plannedDeliveryDate ||
deliverable.actualDeliveryDate ||
deliverable.wfInputDate) && (
<div className="grid gap-x-6 gap-y-2 rounded-md border p-4 text-sm sm:grid-cols-2 md:grid-cols-3">
<div className="grid gap-x-6 gap-y-2 rounded-xl border p-4 text-sm shadow-[var(--shadow-xs)] sm:grid-cols-2 md:grid-cols-3">
{deliverable.cmfSku && (
<div className="sm:col-span-2 md:col-span-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">

View file

@ -12,6 +12,7 @@ import {
MoreHorizontal,
Upload,
ChevronRight,
Users,
} from "lucide-react";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
@ -172,24 +173,24 @@ export default function ProjectDetailPage() {
</div>
<div className="flex items-center gap-2">
{/* View switcher */}
<div className="flex rounded-md border">
<div className="flex rounded-lg border overflow-hidden">
<Link
href={`/projects/${projectId}/table`}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<Table2 className="h-4 w-4" /> Table
</Link>
<Separator orientation="vertical" className="h-auto" />
<Link
href={`/projects/${projectId}/board`}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<LayoutGrid className="h-4 w-4" /> Board
</Link>
<Separator orientation="vertical" className="h-auto" />
<Link
href={`/projects/${projectId}/timeline`}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<GanttChart className="h-4 w-4" /> Timeline
</Link>
@ -260,6 +261,35 @@ export default function ProjectDetailPage() {
<PipelineProgress stages={deliv.stages ?? []} />
</div>
{/* Assigned users */}
{(() => {
const seen = new Set<string>();
const assignees = (deliv.stages ?? [])
.flatMap((s: any) => s.assignments ?? [])
.filter((a: any) => {
if (!a.user || seen.has(a.userId)) return false;
seen.add(a.userId);
return true;
});
if (assignees.length === 0) return null;
return (
<div className="flex items-center gap-1 shrink-0">
<Users className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
<div className="flex gap-1">
{assignees.map((a: any) => (
<Badge
key={a.id}
variant="secondary"
className="text-[10px]"
>
{a.user?.name ?? a.user?.email ?? "Unknown"}
</Badge>
))}
</div>
</div>
);
})()}
{/* Actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -81,7 +81,7 @@ export default function ProjectsPage() {
{Array.isArray(projects) &&
projects.map((project: any) => (
<Card key={project.id} className="group relative">
<Card key={project.id} className="group relative transition-all duration-200 hover:shadow-[var(--shadow-md)]">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1">
@ -137,17 +137,17 @@ export default function ProjectsPage() {
{(project.businessUnit || project.formFactor || project.quarter) && (
<div className="mt-2 flex flex-wrap gap-1">
{project.businessUnit && (
<span className="rounded bg-[var(--muted)] px-1.5 py-0.5 text-[10px] text-[var(--muted-foreground)]">
<span className="rounded-full bg-[var(--muted)] px-2 py-0.5 text-[10px] text-[var(--muted-foreground)]">
{project.businessUnit}
</span>
)}
{project.formFactor && (
<span className="rounded bg-[var(--muted)] px-1.5 py-0.5 text-[10px] text-[var(--muted-foreground)]">
<span className="rounded-full bg-[var(--muted)] px-2 py-0.5 text-[10px] text-[var(--muted-foreground)]">
{project.formFactor}
</span>
)}
{project.quarter && (
<span className="rounded bg-[var(--muted)] px-1.5 py-0.5 text-[10px] text-[var(--muted-foreground)]">
<span className="rounded-full bg-[var(--muted)] px-2 py-0.5 text-[10px] text-[var(--muted-foreground)]">
{project.quarter}
</span>
)}

View file

@ -55,7 +55,7 @@ export default async function LoginPage() {
</p>
</div>
<div className="space-y-2">
<div className="space-y-2.5">
<form
action={async () => {
"use server";
@ -65,7 +65,7 @@ export default async function LoginPage() {
<Button
type="submit"
variant="outline"
className="w-full border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
className="w-full h-11 rounded-xl border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-all shadow-[var(--shadow-sm)] hover:shadow-[var(--shadow-md)]"
>
<GoogleIcon />
Continue with Google
@ -81,7 +81,7 @@ export default async function LoginPage() {
<Button
type="submit"
variant="outline"
className="w-full border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
className="w-full h-11 rounded-xl border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-all shadow-[var(--shadow-sm)] hover:shadow-[var(--shadow-md)]"
>
<MicrosoftIcon />
Continue with Microsoft
@ -91,7 +91,7 @@ export default async function LoginPage() {
<div className="mt-10 border-t pt-6">
<p className="text-[9px] font-semibold tracking-[0.12em] uppercase text-[var(--muted-foreground)]/60">
© {new Date().getFullYear()} Oliver Agency · Brandtech Group
&copy; {new Date().getFullYear()} Oliver Agency &middot; Brandtech Group
</p>
</div>
</div>

View file

@ -6,46 +6,66 @@
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
/* Border radius — Oliver Agency uses 0px (sharp corners throughout) */
--radius-sm: 0px;
--radius-md: 0px;
--radius-lg: 0px;
--radius-xl: 0px;
--radius-2xl: 0px;
--radius-full: 2px;
/* Border radius — modern soft corners, still geometric/professional */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 10px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-full: 9999px;
/* Sidebar */
--sidebar-width: 240px;
--sidebar-width-collapsed: 56px;
/* Color tokens — maps Tailwind utilities to CSS custom properties */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
}
/*
* Light mode Oliver Agency palette
* Pure white + pure black + forest green (#08402c)
* Confirmed from oliver.agency source
* Light mode Oliver Agency palette, modernized
* Warmer whites + forest green (#08402c) + coral accent
*/
:root {
--background: #ffffff;
--foreground: #000000;
--muted: #f5f5f4;
--background: #fafaf9;
--foreground: #0c0c0c;
--muted: #f0efed;
--muted-foreground: #6b6b6b;
--border: #d4d4d4;
--input: #d4d4d4;
--border: #e2e0dc;
--input: #e2e0dc;
--ring: #08402c;
--primary: #08402c;
--primary-foreground: #ffffff;
--secondary: #f5f5f4;
--secondary-foreground: #000000;
--secondary: #f0efed;
--secondary-foreground: #0c0c0c;
--accent: #ee5540;
--accent-foreground: #ffffff;
--destructive: #cc2200;
--destructive-foreground: #ffffff;
--card: #ffffff;
--card-foreground: #000000;
--card-foreground: #0c0c0c;
--popover: #ffffff;
--popover-foreground: #000000;
--popover-foreground: #0c0c0c;
/* Status colors — calibrated for Oliver's precision palette */
--status-blocked: #cc2200;
@ -62,33 +82,39 @@
--chart-3: #b45309;
--chart-4: #ee5540;
--chart-5: #8c8c8c;
/* Shadows */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.06), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
}
/*
* Dark mode deep charcoal + bright green accent
* Dark mode rich charcoal + bright green accent
*/
.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--muted: #141414;
--background: #0c0c0c;
--foreground: #f5f5f4;
--muted: #171715;
--muted-foreground: #a3a3a3;
--border: #262626;
--input: #262626;
--border: #2a2a28;
--input: #2a2a28;
--ring: #22c55e;
--primary: #0fa968;
--primary-foreground: #000000;
--secondary: #1a1a1a;
--secondary-foreground: #fafafa;
--secondary: #1e1e1c;
--secondary-foreground: #f5f5f4;
--accent: #f2725e;
--accent-foreground: #ffffff;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--card: #111111;
--card-foreground: #fafafa;
--popover: #111111;
--popover-foreground: #fafafa;
--card: #141412;
--card-foreground: #f5f5f4;
--popover: #141412;
--popover-foreground: #f5f5f4;
/* Status colors */
--status-blocked: #f87171;
@ -105,6 +131,12 @@
--chart-3: #fbbf24;
--chart-4: #f2725e;
--chart-5: #a3a3a3;
/* Shadows — subtle glow in dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2);
}
@layer base {
@ -157,4 +189,20 @@
text-transform: uppercase;
color: var(--muted-foreground);
}
/* Smooth scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
}

View file

@ -38,13 +38,13 @@ export function PipelineProgress({ stages }: { stages: Stage[] }) {
aria-valuemax={sorted.length}
aria-label={`Pipeline progress: ${completed} of ${sorted.length} stages complete`}
>
<div className="flex gap-0.5">
<div className="flex gap-1 rounded-full overflow-hidden bg-[var(--muted)] p-0.5">
{sorted.map((stage) => (
<Tooltip key={stage.id} delayDuration={0}>
<TooltipTrigger asChild>
<div
className={cn(
"h-2 flex-1 rounded-sm transition-colors",
"h-1.5 flex-1 rounded-full transition-colors",
STATUS_COLORS[stage.status] ?? "bg-[var(--status-not-started)]"
)}
/>

View file

@ -41,7 +41,7 @@ function NavLinks({
return (
<>
<nav className="flex-1 py-3" aria-label="Main navigation">
<nav className="flex-1 py-3 space-y-0.5 px-2" aria-label="Main navigation">
{navItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
@ -52,11 +52,11 @@ function NavLinks({
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 px-4 py-2 text-[11px] font-semibold tracking-[0.08em] uppercase transition-colors",
"flex items-center gap-3 rounded-lg px-3 py-2 text-[11px] font-semibold tracking-[0.08em] uppercase transition-all duration-150",
isActive
? "border-l-2 border-[var(--primary)] bg-[var(--background)] text-[var(--foreground)] pl-[calc(1rem-2px)]"
: "border-l-2 border-transparent text-[var(--muted-foreground)] hover:bg-[var(--background)] hover:text-[var(--foreground)]",
collapsed && "justify-center border-l-0 px-0 pl-0"
? "bg-[var(--primary)] text-[var(--primary-foreground)] shadow-[var(--shadow-sm)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--background)] hover:text-[var(--foreground)]",
collapsed && "justify-center px-2"
)}
>
<Icon className="h-4 w-4 shrink-0" />
@ -79,7 +79,7 @@ function NavLinks({
<Separator />
<nav className="py-3" aria-label="Settings">
<nav className="py-3 space-y-0.5 px-2" aria-label="Settings">
{bottomItems.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
@ -89,11 +89,11 @@ function NavLinks({
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 px-4 py-2 text-[11px] font-semibold tracking-[0.08em] uppercase transition-colors",
"flex items-center gap-3 rounded-lg px-3 py-2 text-[11px] font-semibold tracking-[0.08em] uppercase transition-all duration-150",
isActive
? "border-l-2 border-[var(--primary)] bg-[var(--background)] text-[var(--foreground)] pl-[calc(1rem-2px)]"
: "border-l-2 border-transparent text-[var(--muted-foreground)] hover:bg-[var(--background)] hover:text-[var(--foreground)]",
collapsed && "justify-center border-l-0 px-0 pl-0"
? "bg-[var(--primary)] text-[var(--primary-foreground)] shadow-[var(--shadow-sm)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--background)] hover:text-[var(--foreground)]",
collapsed && "justify-center px-2"
)}
>
<Icon className="h-4 w-4 shrink-0" />
@ -124,7 +124,7 @@ export function Sidebar() {
return (
<aside
className={cn(
"hidden h-screen flex-col border-r bg-[var(--muted)] transition-all duration-200 md:flex",
"hidden h-screen flex-col border-r bg-[var(--card)] transition-all duration-200 md:flex",
isCollapsed ? "w-14" : "w-60"
)}
role="navigation"
@ -148,7 +148,7 @@ export function Sidebar() {
<Button
variant="ghost"
size="icon"
className={cn("h-7 w-7 shrink-0", !isCollapsed && "ml-auto")}
className={cn("h-7 w-7 shrink-0 rounded-lg", !isCollapsed && "ml-auto")}
onClick={toggle}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>

View file

@ -52,13 +52,13 @@ export function Topbar() {
const items = (notifications as any[]) ?? [];
return (
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)] px-4 md:px-5" role="banner">
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)]/80 backdrop-blur-sm px-4 md:px-5 sticky top-0 z-10" role="banner">
<div className="flex items-center gap-2">
<MobileMenuButton />
<Breadcrumbs />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<ThemeToggle />
<Popover>
@ -88,7 +88,7 @@ export function Topbar() {
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<PopoverContent align="end" className="w-80 p-0 rounded-xl shadow-[var(--shadow-lg)]">
<div className="flex items-center justify-between border-b px-3 py-2.5">
<span className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--muted-foreground)]">
Notifications
@ -119,7 +119,7 @@ export function Topbar() {
<div
key={notif.id}
className={cn(
"flex gap-2.5 border-b px-3 py-2.5 last:border-0",
"flex gap-2.5 border-b px-3 py-2.5 last:border-0 transition-colors",
!notif.isRead && "bg-[var(--primary)]/5"
)}
>

View file

@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center border border-transparent px-2 py-0.5 text-[10px] font-semibold tracking-[0.06em] uppercase w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
"inline-flex items-center justify-center rounded-full border border-transparent px-2.5 py-0.5 text-[10px] font-semibold tracking-[0.06em] uppercase w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {

View file

@ -5,17 +5,17 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground shadow-[var(--shadow-sm)] hover:bg-primary/90 hover:shadow-[var(--shadow-md)] active:shadow-[var(--shadow-xs)] active:scale-[0.98]",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white shadow-[var(--shadow-sm)] hover:bg-destructive/90 hover:shadow-[var(--shadow-md)] active:scale-[0.98] focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border bg-background shadow-[var(--shadow-xs)] hover:bg-accent hover:text-accent-foreground hover:shadow-[var(--shadow-sm)] active:scale-[0.98] dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-[var(--shadow-xs)] hover:bg-secondary/80 hover:shadow-[var(--shadow-sm)] active:scale-[0.98]",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
@ -24,7 +24,7 @@ const buttonVariants = cva(
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
lg: "h-10 rounded-lg px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",

View file

@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 border py-6",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-[var(--shadow-sm)] transition-shadow duration-200 hover:shadow-[var(--shadow-md)]",
className
)}
{...props}

View file

@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-sm border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View file

@ -147,7 +147,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View file

@ -39,7 +39,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
className
)}
{...props}
@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border p-6 shadow-[var(--shadow-lg)] duration-200 outline-none sm:max-w-lg",
className
)}
{...props}

View file

@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-md py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-md py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View file

@ -109,7 +109,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}

View file

@ -36,7 +36,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
className
)}
{...props}

View file

@ -29,7 +29,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
"--border-radius": "var(--radius-lg)",
} as React.CSSProperties
}
{...props}

View file

@ -8,7 +8,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
className="relative w-full overflow-x-auto rounded-xl border shadow-[var(--shadow-xs)]"
>
<table
data-slot="table"

View file

@ -41,7 +41,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { PipelineProgress } from "@/components/deliverables/pipeline-progress";
import { ArrowUpDown, Search, Trash2, RefreshCw, X, Columns3 } from "lucide-react";
import { ArrowUpDown, Search, Trash2, RefreshCw, X, Columns3, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@ -69,6 +69,12 @@ interface Deliverable {
isCriticalGate: boolean;
isOptional: boolean;
};
assignments?: {
id: string;
userId: string;
role?: string | null;
user: { id: string; name: string | null; email: string };
}[];
}[];
}
@ -267,6 +273,35 @@ export function DeliverableTable({
),
enableSorting: false,
},
{
id: "assigned",
header: "Assigned",
cell: ({ row }) => {
const seen = new Set<string>();
const assignees = row.original.stages
.flatMap((s) => s.assignments ?? [])
.filter((a) => {
if (seen.has(a.userId)) return false;
seen.add(a.userId);
return true;
});
if (assignees.length === 0) return <span className="text-[var(--muted-foreground)]">{"\u2014"}</span>;
return (
<div className="flex items-center gap-1">
<Users className="h-3.5 w-3.5 shrink-0 text-[var(--muted-foreground)]" />
<div className="flex flex-wrap gap-1">
{assignees.map((a) => (
<Badge key={a.id} variant="secondary" className="text-[10px]">
{a.user?.name ?? a.user?.email ?? "Unknown"}
</Badge>
))}
</div>
</div>
);
},
enableSorting: false,
enableHiding: true,
},
{
accessorKey: "dueDate",
header: ({ column }) => (
@ -458,19 +493,21 @@ export function DeliverableTable({
onCheckedChange={(v) => col.toggleVisibility(!!v)}
className="capitalize"
>
{col.id === "cmfSku"
? "CMF/SKU"
: col.id === "assetCount"
? "Assets"
: col.id === "requestedDueDate"
? "Requested Due"
: col.id === "plannedDeliveryDate"
? "Planned Delivery"
: col.id === "actualDeliveryDate"
? "Actual Delivery"
: col.id === "wfInputDate"
? "WF Input"
: col.id}
{col.id === "assigned"
? "Assigned"
: col.id === "cmfSku"
? "CMF/SKU"
: col.id === "assetCount"
? "Assets"
: col.id === "requestedDueDate"
? "Requested Due"
: col.id === "plannedDeliveryDate"
? "Planned Delivery"
: col.id === "actualDeliveryDate"
? "Actual Delivery"
: col.id === "wfInputDate"
? "WF Input"
: col.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>

View file

@ -9,6 +9,7 @@ import {
} from "@hello-pangea/dnd";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { PipelineProgress } from "@/components/deliverables/pipeline-progress";
@ -141,8 +142,8 @@ export function KanbanBoard({
ref={provided.innerRef}
{...provided.droppableProps}
className={cn(
"min-h-[100px] space-y-2",
snapshot.isDraggingOver && "rounded-md bg-[var(--primary)]/5"
"min-h-[100px] space-y-2 rounded-lg transition-colors",
snapshot.isDraggingOver && "bg-[var(--primary)]/5"
)}
>
{items.map((deliv, index) => (
@ -157,8 +158,8 @@ export function KanbanBoard({
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn(
"cursor-grab border-l-4 transition-shadow",
snapshot.isDragging && "shadow-lg"
"cursor-grab border-l-4 rounded-xl transition-all",
snapshot.isDragging && "shadow-[var(--shadow-lg)] scale-[1.02]"
)}
style={{
...provided.draggableProps.style,
@ -185,6 +186,33 @@ export function KanbanBoard({
{deliv.priority}
</Badge>
</div>
{(() => {
const seen = new Set<string>();
const assignees = (deliv.stages ?? [])
.flatMap((s: any) => s.assignments ?? [])
.filter((a: any) => {
if (!a.user || seen.has(a.userId)) return false;
seen.add(a.userId);
return true;
});
if (assignees.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1">
<Users className="h-3 w-3 shrink-0 text-[var(--muted-foreground)]" />
<div className="flex flex-wrap gap-1">
{assignees.map((a: any) => (
<Badge
key={a.id}
variant="secondary"
className="text-[10px]"
>
{a.user?.name ?? a.user?.email ?? "Unknown"}
</Badge>
))}
</div>
</div>
);
})()}
{deliv.stages?.length > 0 && (
<div className="mt-2">
<PipelineProgress stages={deliv.stages} />

View file

@ -80,7 +80,10 @@ export async function listDeliverables(projectId: string) {
where: { projectId },
include: {
stages: {
include: { template: true },
include: {
template: true,
assignments: { include: { user: true } },
},
orderBy: { template: { order: "asc" } },
},
},