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:
parent
a47c6791d9
commit
edcf31672e
26 changed files with 951 additions and 109 deletions
|
|
@ -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
695
prisma/seed-tracker-data.ts
Normal 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();
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
© {new Date().getFullYear()} Oliver Agency · Brandtech Group
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]"
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue