# Tenders CRUD Management Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add dialog-based Create, Edit, and Delete CRUD operations to the Tenders module on both the list and detail pages.

**Architecture:** Backend: add `update` and `delete` mutations to the existing tRPC tender router (TenderService already has these methods). Frontend: three shared dialog components (CreateTenderDialog, EditTenderDialog, DeleteTenderDialog) following the exact Vendor module dialog pattern, wired into both the list page and the detail page.

**Tech Stack:** Next.js 15 App Router, tRPC v11, Prisma, Tailwind v4, shadcn/ui (Dialog, Button, Input, Select, Textarea), sonner for toasts

---

### Task 1: Add `update` and `delete` mutations to tender router

**Files:**
- Modify: `src/server/routers/tender.router.ts`

- [ ] **Step 1: Add import for `Prisma` error handling at the top of the file**

The file already imports `Prisma` from `@prisma/generated/client` on line 3. No change needed.

- [ ] **Step 2: Add `update` mutation after the `publish` procedure**

Add after line 196 (after `publish` mutation closes):

```typescript
  update: adminProcedure
    .input(
      z.object({
        id: z.string(),
        title: z.string().min(1).optional(),
        description: z.string().optional(),
        eligibility: z.string().optional(),
        submissionDeadline: z.date().optional(),
        tenderType: z.enum(["PROJECT", "CATEGORY"]).optional(),
        projectId: z.string().optional(),
        bundleId: z.string().optional(),
        categoryBundleId: z.string().optional(),
        autoApproveBids: z.boolean().optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const oldTender = await TenderService.getById({ id: input.id });
      if (!oldTender) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Tender not found" });
      }

      const result = await TenderService.update(input);

      publish(CHANNELS.DASHBOARD_STATS);
      await AuditService.log({
        userId: ctx.session.user.id,
        action: "UPDATE",
        entityType: "TENDER_NOTICE",
        entityId: input.id,
        entityName: oldTender.title,
        changes: AuditService.diff(oldTender, result, [
          "title", "description", "eligibility", "submissionDeadline",
          "tenderType", "projectId", "bundleId", "categoryBundleId", "autoApproveBids",
        ]),
      });
      return result;
    }),
```

- [ ] **Step 3: Add `delete` mutation after the `update` mutation**

Add after the `update` mutation closes:

```typescript
  delete: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const oldTender = await TenderService.getById({ id: input.id });
      if (!oldTender) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Tender not found" });
      }

      await TenderService.delete(input.id);

      publish(CHANNELS.DASHBOARD_STATS);
      await AuditService.log({
        userId: ctx.session.user.id,
        action: "DELETE",
        entityType: "TENDER_NOTICE",
        entityId: input.id,
        entityName: oldTender.title,
      });
      return { success: true };
    }),
```

- [ ] **Step 4: Run typecheck to verify**

```bash
npm run typecheck
```

Expected: No errors.

### Task 2: Create DeleteTenderDialog component

**Files:**
- Create: `src/components/tender/DeleteTenderDialog.tsx`

- [ ] **Step 1: Create the DeleteTenderDialog component**

```typescript
"use client";

import { trpc } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
  DialogDescription,
} from "@/components/ui/dialog";
import { toast } from "sonner";

type Props = {
  open: boolean;
  tenderId: string | null;
  tenderName: string;
  onOpenChange: (open: boolean) => void;
};

export function DeleteTenderDialog({ open, tenderId, tenderName, onOpenChange }: Props) {
  const utils = trpc.useUtils();
  const mutation = trpc.tender.delete.useMutation({
    onSuccess: () => {
      toast.success("Tender deleted successfully");
      void utils.tender.list.invalidate();
      void utils.tender.getById.invalidate();
      onOpenChange(false);
    },
    onError: (err) => toast.error(err.message),
  });

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Delete Tender</DialogTitle>
        </DialogHeader>
        <DialogDescription>
          Are you sure you want to delete <strong>{tenderName}</strong>? This action cannot be undone.
        </DialogDescription>
        <DialogFooter>
          <Button variant="outline" onClick={() => onOpenChange(false)}>
            Cancel
          </Button>
          <Button
            variant="destructive"
            onClick={() => tenderId && mutation.mutate({ id: tenderId })}
            disabled={mutation.isPending}
          >
            {mutation.isPending ? "Deleting..." : "Delete"}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```

### Task 3: Create EditTenderDialog component

**Files:**
- Create: `src/components/tender/EditTenderDialog.tsx`

- [ ] **Step 1: Create the EditTenderDialog component**

```typescript
"use client";

import { useEffect, useState } from "react";
import { trpc } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from "@/components/ui/dialog";
import { toast } from "sonner";

type TenderForm = {
  id: string;
  title: string;
  description: string;
  eligibility: string;
  submissionDeadline: string;
  tenderType: "PROJECT" | "CATEGORY";
};

type Props = {
  tender: TenderForm | null;
  open: boolean;
  onOpenChange: (open: boolean) => void;
};

export function EditTenderDialog({ tender, open, onOpenChange }: Props) {
  const utils = trpc.useUtils();
  const [form, setForm] = useState<TenderForm | null>(null);

  useEffect(() => {
    if (tender) setForm(tender);
  }, [tender]);

  const mutation = trpc.tender.update.useMutation({
    onSuccess: () => {
      toast.success("Tender updated successfully");
      void utils.tender.list.invalidate();
      void utils.tender.getById.invalidate();
      onOpenChange(false);
    },
    onError: (err) => toast.error(err.message),
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!form?.id) return;
    mutation.mutate({
      id: form.id,
      title: form.title,
      description: form.description || undefined,
      eligibility: form.eligibility || undefined,
      submissionDeadline: new Date(form.submissionDeadline),
      tenderType: form.tenderType,
    });
  };

  const setField = (field: keyof TenderForm) => (
    e: React.ChangeEvent<HTMLInputElement>,
  ) => setForm((f) => (f ? { ...f, [field]: e.target.value } : f));

  const setSelect = (field: keyof TenderForm) => (value: string) =>
    setForm((f) => (f ? { ...f, [field]: value } : f));

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-lg">
        <DialogHeader>
          <DialogTitle>Edit Tender</DialogTitle>
        </DialogHeader>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div className="grid gap-4 sm:grid-cols-2">
            <div className="sm:col-span-2">
              <Label>Title *</Label>
              <Input
                value={form?.title ?? ""}
                onChange={setField("title")}
                placeholder="Tender title"
                required
              />
            </div>
            <div className="sm:col-span-2">
              <Label>Description</Label>
              <Input
                value={form?.description ?? ""}
                onChange={setField("description")}
                placeholder="Brief description"
              />
            </div>
            <div className="sm:col-span-2">
              <Label>Eligibility Criteria</Label>
              <Input
                value={form?.eligibility ?? ""}
                onChange={setField("eligibility")}
                placeholder="Vendor eligibility requirements"
              />
            </div>
            <div>
              <Label>Submission Deadline *</Label>
              <Input
                type="datetime-local"
                value={form?.submissionDeadline ?? ""}
                onChange={setField("submissionDeadline")}
                required
              />
            </div>
            <div>
              <Label>Tender Type</Label>
              <Select
                value={form?.tenderType ?? "PROJECT"}
                onValueChange={setSelect("tenderType")}
              >
                <SelectTrigger>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="PROJECT">Project</SelectItem>
                  <SelectItem value="CATEGORY">Category</SelectItem>
                </SelectContent>
              </Select>
            </div>
          </div>
          <DialogFooter>
            <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
              Cancel
            </Button>
            <Button type="submit" disabled={mutation.isPending}>
              {mutation.isPending ? "Saving..." : "Save Changes"}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

### Task 4: Create CreateTenderDialog component

**Files:**
- Create: `src/components/tender/CreateTenderDialog.tsx`

- [ ] **Step 1: Create the CreateTenderDialog component**

```typescript
"use client";

import { useState } from "react";
import { trpc } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from "@/components/ui/dialog";
import { toast } from "sonner";

type Props = { open: boolean; onOpenChange: (open: boolean) => void };

const BLANK_FORM = {
  title: "",
  description: "",
  eligibility: "",
  submissionDeadline: "",
  fiscalYearId: "",
  projectId: "",
  tenderType: "PROJECT" as const,
};

export function CreateTenderDialog({ open, onOpenChange }: Props) {
  const utils = trpc.useUtils();
  const [form, setForm] = useState(BLANK_FORM);

  const { data: fiscalYears } = trpc.settings.getFiscalYears.useQuery();
  const { data: activeFY } = trpc.settings.getActiveFiscalYear.useQuery();
  const { data: projects } = trpc.project.list.useQuery();

  const mutation = trpc.tender.create.useMutation({
    onSuccess: (tender) => {
      toast.success("Tender created successfully");
      void utils.tender.list.invalidate();
      onOpenChange(false);
      setForm(BLANK_FORM);
    },
    onError: (err) => toast.error(err.message),
  });

  const set = (field: keyof typeof BLANK_FORM) => (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
  ) => setForm((f) => ({ ...f, [field]: e.target.value }));

  const setSelect = (field: keyof typeof BLANK_FORM) => (value: string) =>
    setForm((f) => ({ ...f, [field]: value }));

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutation.mutate({
      title: form.title,
      description: form.description || undefined,
      eligibility: form.eligibility || undefined,
      submissionDeadline: new Date(form.submissionDeadline),
      fiscalYearId: form.fiscalYearId || activeFY?.id ?? "",
      projectId: form.projectId || undefined,
      tenderType: form.tenderType,
    });
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-lg">
        <DialogHeader>
          <DialogTitle>Create Ad-hoc Tender</DialogTitle>
        </DialogHeader>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div className="grid gap-4 sm:grid-cols-2">
            <div className="sm:col-span-2">
              <Label>Title *</Label>
              <Input
                value={form.title}
                onChange={set("title")}
                placeholder="e.g. Supply of Civil Materials — Lot 1"
                required
              />
            </div>
            <div className="sm:col-span-2">
              <Label>Description</Label>
              <Textarea
                value={form.description}
                onChange={set("description")}
                rows={2}
                placeholder="Brief description of this tender"
              />
            </div>
            <div className="sm:col-span-2">
              <Label>Eligibility Criteria</Label>
              <Textarea
                value={form.eligibility}
                onChange={set("eligibility")}
                rows={2}
                placeholder="Vendor eligibility requirements..."
              />
            </div>
            <div>
              <Label>Fiscal Year *</Label>
              <Select
                value={form.fiscalYearId || activeFY?.id || ""}
                onValueChange={setSelect("fiscalYearId")}
              >
                <SelectTrigger>
                  <SelectValue placeholder="Select fiscal year" />
                </SelectTrigger>
                <SelectContent>
                  {fiscalYears?.map((fy) => (
                    <SelectItem key={fy.id} value={fy.id}>
                      {fy.year} {fy.isActive && "(Active)"}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <div>
              <Label>Tender Type</Label>
              <Select
                value={form.tenderType}
                onValueChange={setSelect("tenderType")}
              >
                <SelectTrigger>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="PROJECT">Project</SelectItem>
                  <SelectItem value="CATEGORY">Category</SelectItem>
                </SelectContent>
              </Select>
            </div>
            <div>
              <Label>Project (optional)</Label>
              <Select
                value={form.projectId}
                onValueChange={setSelect("projectId")}
              >
                <SelectTrigger>
                  <SelectValue placeholder="Select project" />
                </SelectTrigger>
                <SelectContent>
                  {projects?.map((p) => (
                    <SelectItem key={p.id} value={p.id}>
                      {p.code} — {p.name}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <div>
              <Label>Submission Deadline *</Label>
              <Input
                type="datetime-local"
                value={form.submissionDeadline}
                onChange={set("submissionDeadline")}
                required
              />
            </div>
          </div>
          <DialogFooter>
            <Button
              type="button"
              variant="outline"
              onClick={() => {
                onOpenChange(false);
                setForm(BLANK_FORM);
              }}
            >
              Cancel
            </Button>
            <Button type="submit" disabled={mutation.isPending}>
              {mutation.isPending ? "Creating..." : "Create Tender"}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

### Task 5: Update barrel export

**Files:**
- Modify: `src/components/tender/index.ts`

- [ ] **Step 1: Add exports for new dialog components**

Replace the file content with:

```typescript
export { CategoryTenderList } from "./CategoryTenderList";
export { CreateTenderDialog } from "./CreateTenderDialog";
export { EditTenderDialog } from "./EditTenderDialog";
export { DeleteTenderDialog } from "./DeleteTenderDialog";
```

### Task 6: Update the tenders list page with action buttons and dialogs

**Files:**
- Modify: `src/app/(dashboard)/tenders/page.tsx`

- [ ] **Step 1: Add imports for dialog components, icons, and state**

Add these imports after the existing imports at the top:

```typescript
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import { CreateTenderDialog } from "@/components/tender/CreateTenderDialog";
import { EditTenderDialog } from "@/components/tender/EditTenderDialog";
import { DeleteTenderDialog } from "@/components/tender/DeleteTenderDialog";
```

- [ ] **Step 2: Add dialog state variables inside the component function**

Add after line 34 (`const { tenders, isLoading, ... } = useTenders()`):

```typescript
const [createOpen, setCreateOpen] = useState(false);
const [editTender, setEditTender] = useState<{
  id: string;
  title: string;
  description: string;
  eligibility: string;
  submissionDeadline: string;
  tenderType: "PROJECT" | "CATEGORY";
} | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleteName, setDeleteName] = useState("");
```

- [ ] **Step 3: Replace the "Create Ad-hoc Tender" Link with a button that opens the dialog**

Replace lines 48-52:
```typescript
<Link href="/tenders/new">
  <Button variant="outline">
    Create Ad-hoc Tender
  </Button>
</Link>
```

With:
```typescript
<Button variant="outline" onClick={() => setCreateOpen(true)}>
  Create Ad-hoc Tender
</Button>
```

- [ ] **Step 4: Add action column header to the table**

After line 106 (`<TableHead>Status</TableHead>`), add:
```typescript
<TableHead className="w-24">Actions</TableHead>
```

- [ ] **Step 5: Add action buttons to each table row**

After line 130 (closing `</Badge>` and `</TableCell>` for status), before `</TableRow>`, add:
```typescript
<TableCell>
  <div className="flex items-center gap-1">
    <Button
      variant="ghost"
      size="icon"
      onClick={(e) => {
        e.stopPropagation();
        setEditTender({
          id: tender.id,
          title: tender.title,
          description: tender.description ?? "",
          eligibility: tender.eligibility ?? "",
          submissionDeadline: new Date(tender.submissionDeadline)
            .toISOString()
            .slice(0, 16),
          tenderType: tender.tenderType,
        });
      }}
    >
      <Pencil className="h-4 w-4" />
    </Button>
    <Button
      variant="ghost"
      size="icon"
      onClick={(e) => {
        e.stopPropagation();
        setDeleteId(tender.id);
        setDeleteName(tender.title);
      }}
    >
      <Trash2 className="h-4 w-4 text-destructive" />
    </Button>
  </div>
</TableCell>
```

- [ ] **Step 6: Add dialog components before the closing `</div>`**

Before the closing `</div>` of the project tab content (before line 136 `</TabsContent>`), add:

```typescript
<CreateTenderDialog open={createOpen} onOpenChange={setCreateOpen} />
<EditTenderDialog
  tender={editTender}
  open={!!editTender}
  onOpenChange={(open) => { if (!open) setEditTender(null); }}
/>
<DeleteTenderDialog
  open={!!deleteId}
  tenderId={deleteId}
  tenderName={deleteName}
  onOpenChange={(open) => { if (!open) { setDeleteId(null); setDeleteName(""); } }}
/>
```

- [ ] **Step 7: Prevent row click when clicking action buttons**

The current row click handler on line 113 navigates to the detail page. The `e.stopPropagation()` calls in the action buttons already handle this — no change needed.

### Task 7: Update the tender detail page with edit/delete buttons

**Files:**
- Modify: `src/app/(dashboard)/tenders/[tenderId]/page.tsx`

- [ ] **Step 1: Add imports for dialog components and icons**

Add after the existing imports (around line 37):

```typescript
import { Pencil, Trash2 } from "lucide-react";
import { EditTenderDialog } from "@/components/tender/EditTenderDialog";
import { DeleteTenderDialog } from "@/components/tender/DeleteTenderDialog";
```

- [ ] **Step 2: Add dialog state and router**

Add after line 3 (`import { useParams, useRouter } from "next/navigation"`), the router is already imported. Add dialog state after the existing `useState` declaration (around line 3-4):

Find the existing `useState` calls (the detail page may have several) and add:

```typescript
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
```

- [ ] **Step 3: Add Edit and Delete buttons in the page header area**

Find the page header area (likely has a title and action buttons — search for "flex items-center justify-between" or the first heading). Add after whatever action buttons already exist:

```typescript
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
  <Pencil className="mr-1 h-4 w-4" />
  Edit
</Button>
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
  <Trash2 className="mr-1 h-4 w-4" />
  Delete
</Button>
```

- [ ] **Step 4: Add the dialog components**

Add before the closing fragment or main div:

```typescript
<EditTenderDialog
  tender={tender ? {
    id: tender.id,
    title: tender.title,
    description: tender.description ?? "",
    eligibility: tender.eligibility ?? "",
    submissionDeadline: new Date(tender.submissionDeadline).toISOString().slice(0, 16),
    tenderType: tender.tenderType,
  } : null}
  open={editOpen}
  onOpenChange={(open) => { if (!open) setEditOpen(false); }}
/>
<DeleteTenderDialog
  open={deleteOpen}
  tenderId={tender?.id ?? null}
  tenderName={tender?.title ?? ""}
  onOpenChange={(open) => {
    if (!open) setDeleteOpen(false);
    else router.push("/tenders");
  }}
/>
```

### Task 8: Run typecheck and verify

- [ ] **Step 1: Run the typecheck**

```bash
npm run typecheck
```

Expected: No type errors.

- [ ] **Step 2: Run the linter**

```bash
npm run lint
```

Expected: No lint errors.

- [ ] **Step 3: Build the project**

```bash
npm run build
```

Expected: Build succeeds.
