diff --git a/package.json b/package.json index b59b581..bdfa583 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/seed-tracker-data.ts b/prisma/seed-tracker-data.ts new file mode 100644 index 0000000..46de856 --- /dev/null +++ b/prisma/seed-tracker-data.ts @@ -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 = { + 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(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(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(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(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(); + + 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 { + const statuses = new Map(); + 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(); + }); diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index ef3c4f1..f36bacc 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -72,11 +72,11 @@ function KpiCard({ accent?: boolean; }) { return ( - +
-

{value}

+

{value}

{title}

{subtitle && (

@@ -207,7 +207,7 @@ export default function DashboardPage() { @@ -246,7 +246,7 @@ export default function DashboardPage() { (

diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx index 1b02aa0..0778861 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx @@ -157,7 +157,7 @@ export default function DeliverableDetailPage() { deliverable.plannedDeliveryDate || deliverable.actualDeliveryDate || deliverable.wfInputDate) && ( -
+
{deliverable.cmfSku && (

diff --git a/src/app/(app)/projects/[projectId]/page.tsx b/src/app/(app)/projects/[projectId]/page.tsx index 3f5d264..9506c44 100644 --- a/src/app/(app)/projects/[projectId]/page.tsx +++ b/src/app/(app)/projects/[projectId]/page.tsx @@ -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() {

{/* View switcher */} -
+
Table Board Timeline @@ -260,6 +261,35 @@ export default function ProjectDetailPage() {
+ {/* Assigned users */} + {(() => { + const seen = new Set(); + 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 ( +
+ +
+ {assignees.map((a: any) => ( + + {a.user?.name ?? a.user?.email ?? "Unknown"} + + ))} +
+
+ ); + })()} + {/* Actions */} diff --git a/src/app/(app)/projects/page.tsx b/src/app/(app)/projects/page.tsx index e8ae447..7ee9986 100644 --- a/src/app/(app)/projects/page.tsx +++ b/src/app/(app)/projects/page.tsx @@ -81,7 +81,7 @@ export default function ProjectsPage() { {Array.isArray(projects) && projects.map((project: any) => ( - +
@@ -137,17 +137,17 @@ export default function ProjectsPage() { {(project.businessUnit || project.formFactor || project.quarter) && (
{project.businessUnit && ( - + {project.businessUnit} )} {project.formFactor && ( - + {project.formFactor} )} {project.quarter && ( - + {project.quarter} )} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 9dfd060..fc83799 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -55,7 +55,7 @@ export default async function LoginPage() {

-
+
{ "use server"; @@ -65,7 +65,7 @@ export default async function LoginPage() {
diff --git a/src/app/globals.css b/src/app/globals.css index a9ccc8f..180c795 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); + } } diff --git a/src/components/deliverables/pipeline-progress.tsx b/src/components/deliverables/pipeline-progress.tsx index e9d799b..807808e 100644 --- a/src/components/deliverables/pipeline-progress.tsx +++ b/src/components/deliverables/pipeline-progress.tsx @@ -38,13 +38,13 @@ export function PipelineProgress({ stages }: { stages: Stage[] }) { aria-valuemax={sorted.length} aria-label={`Pipeline progress: ${completed} of ${sorted.length} stages complete`} > -
+
{sorted.map((stage) => (
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index b52a64e..55b8b25 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -41,7 +41,7 @@ function NavLinks({ return ( <> -