Version: 1.0 (MVP) Stack: Next.js 14 (App Router) · Prisma · PostgreSQL · Tailwind CSS · shadcn/ui · TanStack Table Prepared for: AI Coding Agent (Cursor / Windsurf)
- You are building a completely NEW Next.js 14 App Router project from scratch. Do not reference any previous codebase.
- Execute ONE phase at a time. After completing each phase, stop and wait for explicit confirmation before proceeding to the next.
- After any Prisma schema change, always run
npx prisma generate. - Do not install any npm package without first listing it and receiving approval.
- Use Tailwind CSS + shadcn/ui for all styling. No other CSS frameworks.
- Use TanStack Table (via shadcn data-table) for every list that requires search, sort, or filter.
- All data tables must use server-side pagination, search, and sort via URL search params. Never fetch all 804 rows to the client.
- The "Switch User" dev toolbar must only render when
process.env.NODE_ENV === 'development'. - Delete any legacy in-memory mock state or
app-context.tsxfiles from previous generations before writing new code. - Every Prisma write that involves claiming an IC must use
$transaction.
| Rule | Detail |
|---|---|
| RSC First | Fetch all initial data in React Server Components using Prisma directly. Pass serialized data down to Client Components for interactivity. |
| Server-Side Tables | All TanStack tables are driven by URL search params (?page, ?search, ?sort, ?order, ?category, ?technology). The RSC reads these params and passes filtered, paginated data to the table. |
| Atomic Claims | The IC claim Server Action must use prisma.$transaction to check the 3-task limit and create the ICTask atomically. No race conditions. |
| 3-IC Limit | An intern can never hold more than 3 tasks with status CLAIMED, IN_PROGRESS, or UNDER_REVIEW simultaneously within a single BatchEnrollment. |
| Alias-First Search | Every search input that queries ICs must search both IC.canonicalName AND ICAlias.name simultaneously using a Prisma OR clause. |
| SPICE Fidelity | Never auto-create a new canonical IC without an Admin explicitly selecting Category and Technology. No defaults allowed on new IC creation. |
| Collision Prevention | When an intern claims an IC, the system must check whether any alias of that IC is already in an active task (CLAIMED, IN_PROGRESS, UNDER_REVIEW) by another intern in the same batch. If so, block the claim. |
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role { INTERN MENTOR ADMIN }
enum ICStatus { UNCLAIMED CLAIMED IN_PROGRESS UNDER_REVIEW COMPLETED FAILED }
enum ICCategory { DIGITAL_LOGIC OP_AMP COMPARATOR VOLTAGE_REGULATOR MIXED_SIGNAL DEVICE_MODEL IP_BLOCK OTHER }
enum Technology { TTL CMOS BICMOS ANALOG MIXED UNSPECIFIED }
enum AddRequestStatus { PENDING APPROVED_AS_NEW MERGED REJECTED }
enum InternshipSeason { SUMMER WINTER SLI OTHER }
model User {
id String @id @default(cuid())
email String @unique
name String
role Role @default(INTERN)
createdAt DateTime @default(now())
batchEnrollments BatchEnrollment[]
addRequests AddRequest[] @relation("RequestedBy")
reviews AddRequest[] @relation("ReviewedBy")
}
model Batch {
id String @id @default(cuid())
year Int
season InternshipSeason
label String
startDate DateTime?
endDate DateTime?
enrollments BatchEnrollment[]
@@unique([year, season])
}
model BatchEnrollment {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id])
userId String
batch Batch @relation(fields: [batchId], references: [id])
batchId String
tasks ICTask[]
@@unique([userId, batchId])
}
model IC {
id String @id @default(cuid())
canonicalName String @unique
description String?
category ICCategory
technology Technology @default(UNSPECIFIED)
datasheetUrl String?
aliases ICAlias[]
tasks ICTask[]
mergeRequests AddRequest[] @relation("MergeCandidate")
createdFromRequest AddRequest? @relation("CreatedFromRequest")
createdAt DateTime @default(now())
seededFrom String?
}
model ICAlias {
id String @id @default(cuid())
name String @unique
ic IC @relation(fields: [icId], references: [id])
icId String
note String?
}
model ICTask {
id String @id @default(cuid())
ic IC @relation(fields: [icId], references: [id])
icId String
enrollment BatchEnrollment @relation(fields: [enrollmentId], references: [id])
enrollmentId String
status ICStatus @default(CLAIMED)
claimedAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
mentorNote String?
@@index([enrollmentId])
@@index([icId])
@@index([status])
}
model AddRequest {
id String @id @default(cuid())
rawName String
normalizedName String?
requester User @relation("RequestedBy", fields: [requesterId], references: [id])
requesterId String
status AddRequestStatus @default(PENDING)
suggestedMergeWith IC? @relation("MergeCandidate", fields: [mergeWithIcId], references: [id])
mergeWithIcId String?
reviewer User? @relation("ReviewedBy", fields: [reviewerId], references: [id])
reviewerId String?
reviewNote String?
createdIc IC? @relation("CreatedFromRequest", fields: [createdIcId], references: [id])
createdIcId String? @unique
createdAt DateTime @default(now())
reviewedAt DateTime?
@@index([status])
@@index([requesterId])
}/ → Redirect to /intern/dashboard or /admin/dashboard based on role cookie
/intern/dashboard → Intern home: active task cards + history table
/intern/browse → IC lobby: full searchable/filterable 804-row table
/admin/dashboard → Admin home: stats cards + quick-action tables
/admin/interns → All enrolled interns (searchable, sortable table)
/admin/interns/[id] → Individual intern detail: full task history
/admin/catalog → Master IC database (searchable, filterable)
/admin/catalog/[id] → Individual IC detail: edit metadata, manage aliases
/admin/review → UNDER_REVIEW task queue (approve / reject)
/admin/requests → AddRequest queue: merge, approve-as-new, or reject
Goal: Working Next.js project with Prisma connected to PostgreSQL and shadcn/ui installed.
Steps:
-
Initialize a clean Next.js 14 project:
npx create-next-app@latest esim-ic-portal \ --typescript --tailwind --eslint --app --src-dir=false
-
Install and initialize shadcn/ui. Install these components:
buttoninputtabledialogselecttoastdropdown-menucommandpopoverbadgecardseparatortextarealabelsonner -
Install Prisma:
npm install prisma @prisma/client npx prisma init
-
Replace the generated
schema.prismawith the Final Schema above verbatim. -
Run:
npx prisma db push npx prisma generate
-
Create
lib/prisma.ts— the singleton Prisma client:import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ["query"] }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
-
Create
lib/queries.ts— reusable Prisma query helpers (to be expanded in later phases):// Placeholder: all shared query functions go here. // Never write raw Prisma queries inline in RSC page files.
Stop. Confirm database connection works before proceeding.
Goal: Populate the database with realistic test data that mirrors the real eSim dataset.
Seed the following data exactly:
Users:
| Name | Role | |
|---|---|---|
| Admin User | admin@esim.fossee.in | ADMIN |
| Mentor User | mentor@esim.fossee.in | MENTOR |
| Arjun Sharma | arjun@intern.esim.in | INTERN |
| Priya Nair | priya@intern.esim.in | INTERN |
| Rohan Mehta | rohan@intern.esim.in | INTERN |
Batches:
| Year | Season | Label |
|---|---|---|
| 2026 | SUMMER | Summer 2026 |
| 2026 | WINTER | Winter 2026 |
Enrollments:
- Arjun → SUMMER 2026
- Priya → SUMMER 2026
- Rohan → SUMMER 2026 and WINTER 2026
Canonical ICs to seed (minimum — use this exact data):
const ics = [
{ canonicalName: "LM741", description: "General purpose op-amp", category: "OP_AMP", technology: "ANALOG", aliases: ["UA741", "µA741", "LM741CN"] },
{ canonicalName: "LM324", description: "Quad op-amp", category: "OP_AMP", technology: "ANALOG", aliases: ["LM324N", "LM324D"] },
{ canonicalName: "LM358", description: "Dual op-amp", category: "OP_AMP", technology: "ANALOG", aliases: ["LM358N", "LM358P"] },
{ canonicalName: "LM339", description: "Quad comparator", category: "COMPARATOR", technology: "ANALOG", aliases: ["LM339N"] },
{ canonicalName: "LM7805", description: "5V linear voltage regulator", category: "VOLTAGE_REGULATOR",technology: "ANALOG", aliases: ["UA7805", "MC7805"] },
{ canonicalName: "LM317", description: "Adjustable voltage regulator", category: "VOLTAGE_REGULATOR",technology: "ANALOG", aliases: ["LM317T"] },
{ canonicalName: "74LS138", description: "3-to-8 line decoder", category: "DIGITAL_LOGIC", technology: "TTL", aliases: ["SN74LS138", "DM74LS138"] },
{ canonicalName: "74HC244", description: "Octal buffer/line driver", category: "DIGITAL_LOGIC", technology: "CMOS", aliases: ["SN74HC244", "74HC244N"] },
{ canonicalName: "74HC595", description: "8-bit shift register", category: "DIGITAL_LOGIC", technology: "CMOS", aliases: ["SN74HC595", "74HC595N"] },
{ canonicalName: "74LS04", description: "Hex inverter", category: "DIGITAL_LOGIC", technology: "TTL", aliases: ["SN74LS04", "DM74LS04"] },
{ canonicalName: "IDT72V201", description: "FIFO memory", category: "MIXED_SIGNAL", technology: "CMOS", aliases: ["72V201"] },
{ canonicalName: "DS90CR285", description: "Channel link serializer", category: "MIXED_SIGNAL", technology: "CMOS", aliases: ["DS90CR285A"] },
{ canonicalName: "AD633", description: "Low cost analog multiplier", category: "MIXED_SIGNAL", technology: "ANALOG", aliases: ["AD633JN"] },
{ canonicalName: "NE555", description: "Timer IC", category: "OTHER", technology: "BICMOS", aliases: ["LM555", "NE555P", "SA555"] },
{ canonicalName: "CD4051B", description: "8-channel analog multiplexer", category: "MIXED_SIGNAL", technology: "CMOS", aliases: ["HCF4051B"] },
];ICTask assignments to seed:
| Intern | IC | Status | Notes |
|---|---|---|---|
| Arjun | LM741 | IN_PROGRESS | Active task |
| Arjun | LM324 | UNDER_REVIEW | Submitted for review |
| Arjun | LM7805 | CLAIMED | Just claimed |
| Arjun | 74LS138 | COMPLETED | Historical |
| Arjun | NE555 | FAILED | mentorNote: "Simulation output does not match datasheet at Vcc=5V" |
| Priya | LM358 | CLAIMED | Active |
| Priya | LM339 | IN_PROGRESS | Active |
| Priya | 74HC595 | COMPLETED | Historical |
| Rohan | AD633 | IN_PROGRESS | Active (SUMMER enrollment) |
Seed execution:
// package.json
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}Run: npx prisma db seed
Stop. Verify seed by running npx prisma studio and confirming all records exist.
Goal: Role-based routing and persistent layouts for both portals. Mock auth for development.
Create lib/auth.ts:
- Store active
userIdandrolein a cookie namedesim_user. - Export
getCurrentUser(): Promise<User | null>— reads cookie, queriesprisma.user.findUnique. - Export
requireRole(role: Role)— throws redirect to/if role doesn't match.
Create components/dev-user-switcher.tsx:
- Renders only when
process.env.NODE_ENV === 'development'. - A
<Select>dropdown listing all seeded users by name and role. - On change: calls a Server Action that sets the
esim_usercookie and redirects to the appropriate portal root. - Render this component in the shared top navbar.
app/layout.tsx — Root layout with Toaster (sonner) and font setup only.
app/page.tsx — Server Component that reads the auth cookie and redirects:
// If role === INTERN → redirect('/intern/dashboard')
// If role === ADMIN || MENTOR → redirect('/admin/dashboard')
// If no cookie → redirect('/intern/dashboard') with first intern as default (dev only)- Server Component. Calls
requireRole('INTERN'). - Top navbar with: eSim logo · "IC Portal" label · Links:
Dashboard·Browse ICs· Dev switcher (dev only) · User name display (right side). - No sidebar — interns only have 2 navigation destinations.
- Server Component. Calls
requireRoleforADMINorMENTOR. - Persistent left sidebar (fixed width: 240px) containing:
- eSim / FOSSEE logo at top
- Nav links:
- 📊 Dashboard →
/admin/dashboard - 👥 Interns →
/admin/interns - 🗂️ IC Catalog →
/admin/catalog - ✅ Review Queue →
/admin/review(show pending count badge) - 📨 Name Requests →
/admin/requests(show pending count badge)
- 📊 Dashboard →
- User name + role badge at bottom of sidebar
- Top header bar: breadcrumb (current page) · Dev switcher (dev only, right side)
Stop. Confirm layout renders for both roles before proceeding.
Data fetching (RSC):
// lib/queries.ts
export async function getInternDashboardData(userId: string) {
return prisma.batchEnrollment.findFirst({
where: { userId },
include: {
batch: true,
tasks: {
include: { ic: { include: { aliases: true } } },
orderBy: { claimedAt: "desc" },
},
},
});
}UI — Enrollment Card:
- Intern name (large heading)
- Batch label badge:
SUMMER 2026 - Stats row:
Active: N/3·Completed: N·Failed: N - If
active === 3: show a yellow warning banner: "You have reached the 3-task limit. Complete or submit a task to claim more."
UI — Active Task Cards (max 3, rendered from active task filter):
Active = status in [CLAIMED, IN_PROGRESS, UNDER_REVIEW]
Each card contains:
- IC canonical name (large, font-mono)
- Alias pills (grey badges) — all aliases
- Category badge (colored) + Technology badge
- Status badge (color-coded: CLAIMED=blue, IN_PROGRESS=yellow, UNDER_REVIEW=purple)
- "Claimed N days ago" (relative time)
- Datasheet link button (if
datasheetUrlis not null) - If
mentorNoteexists and status isFAILED: red callout box with note text - Status update button:
CLAIMED→ "Mark In Progress" button → Server Action sets status toIN_PROGRESSIN_PROGRESS→ "Submit for Review" button → Server Action sets status toUNDER_REVIEWUNDER_REVIEW→ Disabled grey button: "Awaiting Mentor Review"
UI — Task History Table (bottom of page):
Shows all tasks with status COMPLETED or FAILED. Client Component using TanStack Table.
Columns: IC Name · Status · Claimed Date · Completed Date · Mentor Note
Sortable by all date columns. Default sort: completedAt descending. No pagination (history is per-intern, bounded).
URL params: ?search=&category=&technology=&status=available&page=1&pageSize=50
RSC data fetch (lib/queries.ts):
export async function getIcsForLobby({
search, category, technology, showAll, page, pageSize, enrollmentId
}: LobbyParams) {
const where = {
AND: [
search ? {
OR: [
{ canonicalName: { contains: search, mode: "insensitive" } },
{ aliases: { some: { name: { contains: search, mode: "insensitive" } } } },
],
} : {},
category ? { category: category as ICCategory } : {},
technology ? { technology: technology as Technology } : {},
!showAll ? {
NOT: {
tasks: {
some: {
status: { in: ["CLAIMED", "IN_PROGRESS", "UNDER_REVIEW"] },
enrollment: { batchId: currentBatchId },
},
},
},
} : {},
],
};
const [items, total] = await Promise.all([
prisma.iC.findMany({
where,
include: { aliases: true, tasks: { include: { enrollment: { include: { user: true } } } } },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { canonicalName: "asc" },
}),
prisma.iC.count({ where }),
]);
return { items, total, page, pageSize };
}UI — Filter Bar (Client Component):
- Global search input (debounced 300ms, updates URL param)
- Category
<Select>— options with counts:OP_AMP (124) - Technology
<Select>— options with counts - Status toggle:
Available Only(default) |Show All - "Request New IC" button → opens modal (see 3c)
- Result count display: "Showing 50 of 312 available ICs"
UI — Data Table (TanStack Table, server-side):
| Column | Sortable | Detail |
|---|---|---|
| Canonical Name | ✓ | font-mono, bold |
| Aliases | — | Up to 3 grey badge pills, then +N more tooltip |
| Category | ✓ | Colored badge |
| Technology | ✓ | Badge |
| Description | — | Truncated at 60 chars, full text on hover tooltip |
| Status | ✓ | AVAILABLE (green) · TAKEN (grey, shows assignee name) · COMPLETED (blue) |
| Actions | — | Claim button |
Claim button rules:
- If IC status is not AVAILABLE → button disabled, tooltip: "Currently claimed by [Name]"
- If intern is at 3 active tasks → button disabled, tooltip: "Release a task to claim more"
- If IC available and intern under limit → active green "Claim" button
On Claim button click:
- Opens confirmation
<Dialog>:- Title: "Claim [IC Name]?"
- Body: Shows all aliases, category, technology
- Footer: Cancel · "Confirm Claim" (triggers Server Action)
Claim Server Action (app/actions/claim-ic.ts):
"use server"
// Must use prisma.$transaction:
// 1. Count intern's active tasks in this enrollment. If >= 3, throw error.
// 2. Check if any ICAlias of this IC has an active task in the same batch. If yes, throw collision error.
// 3. Create ICTask with status CLAIMED.
// All three steps in a single $transaction.On success: revalidatePath('/intern/browse') and revalidatePath('/intern/dashboard').
On collision error: show toast: "This IC (or its alias [alias name]) is already claimed by [Intern Name] in your batch."
Trigger: "Request New IC" button on /intern/browse.
Modal contents:
- Input: "IC Name or Part Number" (e.g.,
UA741,LM386) - Submit button: "Check & Request"
Server Action logic (app/actions/request-ic.ts):
"use server"
// Step 1: Normalize input (uppercase, strip spaces).
// Step 2: Check if canonicalName already exists → toast "Already exists, search for [name]"
// Step 3: Check if name matches any ICAlias → toast "This is an alias of [canonical]. Claim that IC instead."
// Step 4: Regex whitelist check:
// Known prefixes: /^(74|54|LM|LT|AD|TL|NE|SA|MC|UA|CD|HCF|SN|DS|IDT|MAX|ICL|OP|CA|RC)/i
// If matches → auto-create IC with UNCLAIMED status, category=OTHER, technology=UNSPECIFIED.
// Show toast: "[Name] added to catalog. You can now claim it."
// Revalidate /intern/browse.
// If does not match → create AddRequest with status PENDING.
// Show toast: "Request for [Name] sent to admin for review."Data fetching (RSC — all in parallel with Promise.all):
const [
totalICs, unclaimedICs, inProgressICs, completedICs,
activeInterns, pendingReviews, pendingRequests,
recentActivity, awaitingReview
] = await Promise.all([
prisma.iC.count(),
prisma.iC.count({ where: { tasks: { none: { status: { in: ["CLAIMED","IN_PROGRESS","UNDER_REVIEW","COMPLETED"] } } } } }),
prisma.iC.count({ where: { tasks: { some: { status: { in: ["CLAIMED","IN_PROGRESS"] } } } } }),
prisma.iC.count({ where: { tasks: { some: { status: "COMPLETED" } } } }),
prisma.batchEnrollment.count({ where: { batch: { year: 2026 } } }),
prisma.iCTask.count({ where: { status: "UNDER_REVIEW" } }),
prisma.addRequest.count({ where: { status: "PENDING" } }),
// last 10 task updates in 48h
prisma.iCTask.findMany({ where: { updatedAt: { gte: new Date(Date.now() - 48*60*60*1000) } }, include: { ic: true, enrollment: { include: { user: true } } }, orderBy: { updatedAt: "desc" }, take: 10 }),
// oldest 10 UNDER_REVIEW tasks
prisma.iCTask.findMany({ where: { status: "UNDER_REVIEW" }, include: { ic: true, enrollment: { include: { user: true } } }, orderBy: { updatedAt: "asc" }, take: 10 }),
]);UI Layout:
Stats Cards Row (4 cards):
- Total ICs:
804(with breakdown: Unclaimed · In Progress · Completed) - Active Interns (2026):
N - Pending Reviews:
N— clicking navigates to/admin/review - Name Requests:
N— clicking navigates to/admin/requests
Side-by-side compact tables below:
Left — "Awaiting Review (oldest first)":
- Columns: IC Name · Intern · Days Waiting · Quick Actions (Approve / Reject buttons)
- Days waiting > 7: highlight row in amber
Right — "Recent Activity (last 48h)":
- Columns: Intern Name · Action (e.g., "Claimed LM741") · Time Ago
URL params: ?search=&batch=&status=&page=1&pageSize=25&sort=name&order=asc
RSC data fetch:
export async function getAdminInternsList({ search, batchId, statusFilter, page, pageSize, sort, order }) {
return prisma.batchEnrollment.findMany({
where: {
batch: batchId ? { id: batchId } : undefined,
user: search ? { name: { contains: search, mode: "insensitive" } } : undefined,
},
include: {
user: true,
batch: true,
tasks: { select: { status: true, updatedAt: true } },
},
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: sort === "name" ? { user: { name: order } } : { [sort]: order },
});
}Filter Bar:
- Search by intern name
- Batch
<Select>— "All Batches" · "Summer 2026" · "Winter 2026" - Status filter
<Select>: "All" · "Has Active Tasks" · "Has Failed Tasks" · "All Completed"
Table Columns:
| Column | Sortable | Detail |
|---|---|---|
| Name | ✓ | Clickable → /admin/interns/[id] |
| Batch | ✓ | Badge |
| Active Tasks | ✓ | 2/3 — red badge if at limit (3/3) |
| Completed | ✓ | Count |
| Failed | ✓ | Count — red text if > 0 |
| Last Activity | ✓ | Relative time from most recent updatedAt |
| Actions | — | "View" button |
RSC data fetch:
export async function getInternDetail(enrollmentId: string) {
return prisma.batchEnrollment.findUnique({
where: { id: enrollmentId },
include: {
user: true,
batch: true,
tasks: {
include: { ic: { include: { aliases: true } } },
orderBy: { claimedAt: "desc" },
},
},
});
}UI:
- Header: Intern name · Email · Batch label · Enrolled date
- Stats row: Active
N/3· CompletedN· FailedN· Completion rateN% - Full task history TanStack Table:
- Columns: IC Name (mono) · Aliases · Status · Claimed Date · Completed Date · Days Taken · Mentor Note
- Filter by status (All · Active · Completed · Failed)
- Sortable by all date and status columns
- For any task with status
UNDER_REVIEW: show inline Approve and Reject buttons in the Actions column (same Server Actions as/admin/review)
URL params: ?search=&category=&technology=&quality=&page=1&pageSize=50&sort=canonicalName&order=asc
RSC data fetch:
export async function getAdminCatalog({ search, category, technology, quality, page, pageSize, sort, order }) {
const where = {
AND: [
search ? {
OR: [
{ canonicalName: { contains: search, mode: "insensitive" } },
{ aliases: { some: { name: { contains: search, mode: "insensitive" } } } },
],
} : {},
category ? { category } : {},
technology ? { technology } : {},
quality === "no_datasheet" ? { datasheetUrl: null } : {},
quality === "unspecified_tech" ? { technology: "UNSPECIFIED" } : {},
quality === "no_aliases" ? { aliases: { none: {} } } : {},
],
};
const [items, total] = await Promise.all([
prisma.iC.findMany({
where,
include: {
aliases: true,
_count: { select: { tasks: true, aliases: true } },
tasks: { select: { status: true }, orderBy: { claimedAt: "desc" }, take: 1 },
},
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { [sort]: order },
}),
prisma.iC.count({ where }),
]);
return { items, total };
}Filter Bar:
- Global search (canonical name + aliases)
- Category multiselect (checkboxes in a popover)
- Technology multiselect (checkboxes in a popover)
- Data Quality filter
<Select>: "All" · "UNSPECIFIED Technology" · "No Datasheet" · "No Aliases" - "Add New IC" button (top right) → opens Add IC Dialog
Table Columns:
| Column | Sortable | Detail |
|---|---|---|
| Canonical Name | ✓ | font-mono · links to /admin/catalog/[id] |
| Aliases | — | Count badge: 3 aliases — hover shows list |
| Category | ✓ | Colored badge |
| Technology | ✓ | Badge — UNSPECIFIED renders as orange warning badge |
| Description | — | Truncated at 60 chars |
| Datasheet | — | Link icon if URL exists, warning icon if null |
| Assignment | ✓ | Available (green) · In Progress — [Name] (yellow) · Completed (blue) |
| Actions | — | Edit button |
Add New IC Dialog:
- Fields: Canonical Name · Description · Category (required, no default) · Technology (required, no default) · Datasheet URL (optional)
- Canonical Name uniqueness check on submit
- Server Action creates IC with explicit category and technology (SPICE fidelity enforcement)
RSC data fetch:
export async function getICDetail(icId: string) {
return prisma.iC.findUnique({
where: { id: icId },
include: {
aliases: true,
tasks: {
include: { enrollment: { include: { user: true, batch: true } } },
orderBy: { claimedAt: "desc" },
},
createdFromRequest: { include: { requester: true } },
},
});
}UI:
-
Header: Canonical name (large, mono) · Category badge · Technology badge
-
Edit inline form for: Description · Category · Technology · Datasheet URL
- "Save Changes" Server Action:
prisma.iC.update(...)
- "Save Changes" Server Action:
-
Alias Manager section:
- List of all aliases with individual delete buttons
- On delete: checks no active task depends on this alias, then deletes
- "Add Alias" input + button
- Server Action: checks
prisma.iCAlias.findUnique({ where: { name } })first — if exists, show error: "This name already exists as an alias for [other IC name]"
-
Full Assignment History table (all batches):
- Columns: Intern Name · Batch · Status · Claimed Date · Completed Date · Days Taken · Mentor Note
- Sortable by status and date
-
"Merge into another IC" button → opens same Combobox Merge Dialog as in
/admin/requests
URL params: ?search=&batch=&page=1&pageSize=25&sort=updatedAt&order=asc
RSC data fetch:
export async function getReviewQueue({ search, batchId, page, pageSize }) {
return prisma.iCTask.findMany({
where: {
status: "UNDER_REVIEW",
enrollment: {
batchId: batchId || undefined,
user: search ? { name: { contains: search, mode: "insensitive" } } : undefined,
},
},
include: {
ic: { include: { aliases: true } },
enrollment: { include: { user: true, batch: true } },
},
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { updatedAt: "asc" }, // oldest first by default
});
}Filter Bar:
- Search by intern name or IC name
- Batch
<Select> - Sort toggle: "Oldest First" (default) | "Newest First"
Table Columns:
| Column | Sortable | Detail |
|---|---|---|
| IC Name | ✓ | font-mono |
| Aliases | — | Grey pills |
| Intern | ✓ | Name + batch badge |
| Submitted | ✓ | Relative time |
| Days Waiting | ✓ | Number — red if > 7 days |
| Actions | — | Approve · Reject buttons |
Approve Server Action:
- Sets
status = COMPLETED,completedAt = new Date() revalidatePath('/admin/review'),revalidatePath('/admin/dashboard')
Reject flow:
- Reject button opens
<Dialog>:- Title: "Reject Task — [IC Name]"
- Mandatory
<Textarea>labeled "Reason for rejection (intern will see this)" - Submit is disabled if textarea is empty
- Server Action: sets
status = FAILED, savesmentorNote
URL params: ?status=PENDING&page=1&pageSize=25
RSC data fetch:
export async function getAddRequests({ status, page, pageSize }) {
return prisma.addRequest.findMany({
where: { status: status || "PENDING" },
include: { requester: true, reviewer: true, suggestedMergeWith: true, createdIc: true },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: "asc" },
});
}Filter Bar:
- Status tabs:
Pending (N)·Approved (N)·Merged (N)·Rejected (N)
Table Columns:
| Column | Detail |
|---|---|
| Requested Name | Raw string intern typed — e.g., ua 741 |
| Normalized | Auto-uppercased, stripped — e.g., UA741 |
| Requested By | Intern name |
| Date | Relative time |
| Status | Badge |
| Actions | "Review" button (opens Review Dialog) |
Review Dialog (The Smart Merge UI):
This is a single <Dialog> with a mode toggle inside it.
Header: "Review IC Request — [Raw Name]" Requested by: [Intern Name] · [Date]
Two action paths inside the dialog:
Path A — Merge with Existing IC:
- Searchable
<Command>combobox (shadcn Command component) - Label: "Search existing ICs by canonical name or alias..."
- On keystroke: Server Action queries:
prisma.iC.findMany({ where: { OR: [ { canonicalName: { contains: query, mode: "insensitive" } }, { aliases: { some: { name: { contains: query, mode: "insensitive" } } } }, ], }, include: { aliases: true }, take: 10, })
- Results display:
LM741·[µA741, UA741, LM741CN](show aliases beneath) - Admin selects target IC
- Optional "Reviewer Note" textarea
- "Approve & Merge" button → Server Action:
- Check
prisma.iCAlias.findUnique({ where: { name: normalizedName } })— if exists, abort with error "This alias already exists" prisma.iCAlias.create({ data: { name: normalizedName, icId: selectedIcId, note: reviewNote } })prisma.addRequest.update({ where: { id }, data: { status: "MERGED", reviewerId, reviewNote, reviewedAt } })
- Check
Path B — Approve as New Canonical IC:
- Toggle/tab: "Create New IC"
- Fields (ALL required, no defaults):
- Category
<Select>— explicitly required - Technology
<Select>— explicitly required - Description
<Textarea>(optional but shown) - Datasheet URL
<Input>(optional)
- Category
- "Approve as New IC" button → Server Action:
prisma.iC.create({ data: { canonicalName: normalizedName, category, technology, description, datasheetUrl } })prisma.addRequest.update({ status: "APPROVED_AS_NEW", createdIcId: newIc.id, reviewerId, reviewedAt })
Path C — Reject:
- "Reject" text button (subtle, bottom left of dialog)
- Opens confirmation with mandatory note
- Server Action:
prisma.addRequest.update({ status: "REJECTED", reviewNote, reviewedAt })
esim-ic-portal/
├── app/
│ ├── layout.tsx # Root layout (Toaster only)
│ ├── page.tsx # Role-based redirect
│ ├── intern/
│ │ ├── layout.tsx # Intern top navbar layout
│ │ ├── dashboard/
│ │ │ └── page.tsx # RSC: task cards + history
│ │ └── browse/
│ │ └── page.tsx # RSC: IC lobby table
│ └── admin/
│ ├── layout.tsx # Admin sidebar layout
│ ├── dashboard/
│ │ └── page.tsx # RSC: stats + quick tables
│ ├── interns/
│ │ ├── page.tsx # RSC: intern list table
│ │ └── [id]/
│ │ └── page.tsx # RSC: intern detail
│ ├── catalog/
│ │ ├── page.tsx # RSC: IC catalog table
│ │ └── [id]/
│ │ └── page.tsx # RSC: IC detail + alias mgr
│ ├── review/
│ │ └── page.tsx # RSC: review queue table
│ └── requests/
│ └── page.tsx # RSC: add request queue
├── components/
│ ├── dev-user-switcher.tsx # Dev-only user switcher
│ ├── intern/
│ │ ├── task-card.tsx # Active task card (Client)
│ │ ├── task-history-table.tsx # History TanStack table (Client)
│ │ ├── lobby-table.tsx # IC lobby TanStack table (Client)
│ │ ├── lobby-filters.tsx # Filter bar (Client)
│ │ └── request-ic-modal.tsx # Request IC dialog (Client)
│ └── admin/
│ ├── intern-table.tsx # Interns TanStack table (Client)
│ ├── catalog-table.tsx # IC catalog TanStack table (Client)
│ ├── review-table.tsx # Review queue TanStack table (Client)
│ ├── requests-table.tsx # AddRequest TanStack table (Client)
│ ├── reject-dialog.tsx # Mandatory note reject dialog (Client)
│ ├── review-request-dialog.tsx # Smart merge dialog (Client)
│ └── add-ic-dialog.tsx # New IC creation dialog (Client)
├── app/
│ └── actions/
│ ├── claim-ic.ts # Atomic claim Server Action
│ ├── update-task-status.ts # Status update Server Action
│ ├── request-ic.ts # Request new IC Server Action
│ ├── approve-task.ts # Review approve Server Action
│ ├── reject-task.ts # Review reject Server Action
│ ├── merge-request.ts # AddRequest merge Server Action
│ ├── approve-new-ic.ts # AddRequest approve-as-new Server Action
│ ├── reject-request.ts # AddRequest reject Server Action
│ ├── add-alias.ts # Add IC alias Server Action
│ └── update-ic.ts # Edit IC metadata Server Action
├── lib/
│ ├── prisma.ts # Singleton Prisma client
│ ├── auth.ts # Cookie auth helpers
│ └── queries.ts # All reusable Prisma query functions
└── prisma/
├── schema.prisma # Final schema (above)
└── seed.ts # Full seed script
Every table page follows this pattern — never deviate:
// app/admin/catalog/page.tsx (RSC)
export default async function CatalogPage({ searchParams }) {
const { search, category, technology, page } = searchParams;
const { items, total } = await getAdminCatalog({ search, category, technology, page: Number(page) || 1, pageSize: 50 });
return <CatalogTable data={items} total={total} />;
}
// components/admin/catalog-table.tsx ("use client")
"use client"
// TanStack Table with columns defined here.
// Filtering/sorting updates URL params via useRouter().push() — does NOT re-filter client-side.
// Pagination buttons do router.push(`?page=${newPage}`) — triggers RSC refetch.// Inside prisma.$transaction:
const icWithAliases = await tx.iC.findUnique({
where: { id: icId },
include: { aliases: true },
});
const allNames = [icWithAliases.canonicalName, ...icWithAliases.aliases.map(a => a.name)];
const collision = await tx.iCTask.findFirst({
where: {
status: { in: ["CLAIMED", "IN_PROGRESS", "UNDER_REVIEW"] },
enrollment: { batchId: currentBatchId },
ic: {
OR: [
{ canonicalName: { in: allNames } },
{ aliases: { some: { name: { in: allNames } } } },
],
},
},
include: { enrollment: { include: { user: true } } },
});
if (collision) throw new Error(`Collision: already claimed by ${collision.enrollment.user.name}`);// Category → badge color
const categoryColors = {
OP_AMP: "blue", COMPARATOR: "indigo", DIGITAL_LOGIC: "violet",
VOLTAGE_REGULATOR: "green", MIXED_SIGNAL: "orange", DEVICE_MODEL: "cyan",
IP_BLOCK: "pink", OTHER: "grey",
};
// Technology → badge color
const techColors = {
TTL: "red", CMOS: "blue", BICMOS: "purple", ANALOG: "green", MIXED: "orange",
UNSPECIFIED: "yellow", // Always render as a warning
};
// Status → badge color
const statusColors = {
CLAIMED: "blue", IN_PROGRESS: "yellow", UNDER_REVIEW: "purple",
COMPLETED: "green", FAILED: "red", UNCLAIMED: "grey",
};| Phase | Deliverable | Done? |
|---|---|---|
| 0 | Next.js + Prisma + shadcn/ui scaffold, DB connected | ☒ |
| 1 | Seed script runs, all users/ICs/tasks in DB | ☒ |
| 2 | Role-based layouts, nav, dev switcher working | ☒ |
| 3a | Intern dashboard: task cards + history table | ☒ |
| 3b | Intern browse: server-side table with search/filter/claim | ☒ |
| 3c | Request New IC modal with regex + AddRequest fallback | ☒ |
| 4a | Admin dashboard: stats cards + quick tables | ☒ |
| 4b | Admin interns: searchable, sortable table | ☒ |
| 4c | Admin intern detail: full task history + inline review | ☒ |
| 4d | Admin catalog: filtered table + Add IC dialog | ☒ |
| 4e | Admin IC detail: edit metadata + alias manager | ☒ |
| 5a | Admin review queue: approve + mandatory-note reject | ☒ |
| 5b | approve-as-new | ☒ |
Do not mark a phase done until the UI renders correctly with real seeded data and all Server Actions succeed without errors.
Step 1: NextAuth Implementation (The Security Gate)
- Install
next-authand configure the Google Provider. signInCallback: Only allow sign-in if the user's email already exists in theUsertable. If it does not exist, redirect to an error page: "Access Denied: Your email is not whitelisted. Please use the exact email you provided to FOSSEE."session/jwtCallback: Pass theUser.idandRoleinto the NextAuth session so Server Components can use them.- Name Capture: On their very first successful login, update the Prisma User record with the name provided by the Google profile.
- Remove all legacy Mock Auth code.
Step 2: The Batch Management UI (/admin/batches)
- Create a dedicated page for Admins to manage intern rosters.
- Dropdown/selector to choose the active Batch.
- Display a TanStack Table showing enrolled interns for the selected batch. Columns: Email, Name, Joined Date, Actions.
- Action 1 (Bulk Add): Button opens a Dialog with a Textarea for comma-separated emails. A Server Action parses these, upserts into the
Usertable, and creates aBatchEnrollment. - Action 2 (Remove): Trash icon to delete that specific
BatchEnrollment.