Compare commits

..

3 Commits

18 changed files with 2063 additions and 249 deletions

View File

@ -212,11 +212,21 @@ Customer-specific step instance (actual execution unit).
| ID | Feature | Description | | ID | Feature | Description |
|----|---------|-------------| |----|---------|-------------|
| FCS-001 | View Inherited Steps | See template steps applied to customer | | FCS-001 | View Inherited Steps | See template steps applied to customer |
| FCS-002 | Override Step Content | Modify step content for specific customer | | FCS-002 | Override Step Content | Modify step content for specific customer (even after release activation) |
| FCS-003 | Add Custom Step | Add step only for specific customer | | FCS-003 | Add Custom Step | Add step only for specific customer; option to "Add to template" (applies to all customers) |
| FCS-004 | Skip Step | Mark step as skipped with mandatory reason | | FCS-004 | Skip Step | Mark step as skipped with mandatory reason |
| FCS-005 | Revert Override | Reset to template content | | FCS-005 | Revert Override | Reset to template content |
| FCS-006 | Remove Custom Step | Delete customer-specific step | | FCS-006 | Remove Custom Step | Delete customer-specific step (not from template) |
| FCS-007 | Edit Template Steps | Edit template steps after activation (affects all customers with pending status) |
| FCS-008 | Mixed Step Ordering | Custom steps can be inserted between template steps; use decimal ordering |
**Add Custom Step Flow:**
1. User clicks "Add Custom Step" for a customer
2. Fill in step details (name, type, content, category)
3. Checkbox "Add to template" (unchecked by default):
- **Unchecked**: Step is created only for this customer (is_custom=true)
- **Checked**: Step is added to template AND all customers get this step immediately
4. Set order position (insert before/after existing steps)
### 4.6 Execution Tracking ### 4.6 Execution Tracking
@ -400,32 +410,82 @@ Customer-specific step instance (actual execution unit).
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
``` ```
### 5.7 Step Content Modal ### 5.7 Step Detail Side Panel
The side panel opens when clicking a step cell in the matrix view, showing customer-specific content and actions.
**Side Panel Layout:**
```
┌──────────────────────────────────────────────────────────────────────────┐
│ Release: v2.5.0 [X] Close │
│ Step: Run Migration SQL │
├──────────────────────────────────────────────────────────────────────────┤
│ Customer: customer-a (prod-us/cust-a-prod) │
│ Category: Deploy | Type: SQL | Status: 🔄 Pending │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ SOURCE INFO: │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 📋 From Template [Override Content] [Reset to Template] │ │
│ │ ⭐ Custom Step [Edit] [Delete] │ │
│ │ ⚠️ Overridden [View Original] [Reset to Template] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ CONTENT: │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 1 │ -- Custom migration for customer-a │ │
│ │ 2 │ ALTER TABLE users ADD COLUMN custom_field VARCHAR(100); │ │
│ │ 3 │ UPDATE users SET custom_field = 'value' WHERE id > 100; │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ [📋 Copy] [⬇️ Download] │
│ │
│ EXECUTION: │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Notes: │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ [Enter execution notes... ] │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [Mark as Done] [Skip] [Revert] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ HISTORY: │
│ • 2024-01-15 10:30 - Created from template │
│ • 2024-01-15 10:35 - Content overridden │
│ • 2024-01-15 10:45 - Marked as Done │
│ │
└──────────────────────────────────────────────────────────────────────────┘
```
**Actions by Source Type:**
| Source | Available Actions |
|--------|-------------------|
| Template (not overridden) | View, Copy, Mark Done, Skip, Override Content |
| Overridden | View Custom, View Original, Reset to Template, Mark Done, Skip |
| Custom Step | Edit, Delete, Mark Done, Skip |
### 5.8 Add Custom Step Dialog
``` ```
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
│ Step: Run Migration SQL [X] │ │ Add Custom Step for customer-a [X] │
├─────────────────────────────────────────────────────────────────┤
│ Category: Deploy | Type: SQL │
│ Source: Template (edited for this customer) │
│ Customer: customer-a (prod-us/cust-a-prod) │
├─────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────┤
│ Step Name: │
│ [ ] │
│ │
│ Category: [Deploy ▼] Type: [Bash ▼] │
│ │
│ Content: │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ ```sql │ │ │ │ │ │
│ │ -- Custom migration for customer-a │ │ │ │ │ │
│ │ ALTER TABLE users ADD COLUMN custom_field VARCHAR(100); │ │
│ │ UPDATE users SET custom_field = 'value' WHERE id > 100; │ │
│ │ ``` │ │
│ └─────────────────────────────────────────────────────────┘ │ │ └─────────────────────────────────────────────────────────┘ │
│ │ │ │
│ [📋 Copy] [⬇️ Download] │ │ Insert Position: [Before "Deploy App" ▼] │
│ │ │ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ☐ Add to template (apply to all customers) │
│ │ Execution Notes: │ │
│ │ [Optional: Add notes about this execution... ] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │ │
│ [Cancel] [Mark as Done] │ │ [Cancel] [Add Step] │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
``` ```
@ -450,10 +510,17 @@ Customer-specific step instance (actual execution unit).
- **BR-R04**: Reverting a step sets status to `reverted` but preserves history - **BR-R04**: Reverting a step sets status to `reverted` but preserves history
### 6.4 Step Rules ### 6.4 Step Rules
- **BR-S01**: Custom steps (is_custom=true) don't affect other customers or template - **BR-S01**: Custom steps (is_custom=true) don't affect other customers or template (unless "Add to template" is checked)
- **BR-S02**: Override (is_overridden=true) preserves link to template for reference - **BR-S02**: Override (is_overridden=true) preserves link to template for reference
- **BR-S03**: Skipping requires mandatory reason - **BR-S03**: Skipping requires mandatory reason
- **BR-S04**: Order index is per category (deploy/verify have separate ordering) - **BR-S04**: Order index is per category (deploy/verify have separate ordering)
- **BR-S05**: Template steps use integer orderIndex (0, 1, 2...); custom steps use decimals (0.5, 1.5...) to insert between
- **BR-S06**: When "Add to template" is checked during custom step creation:
- Step is added to step_templates table
- All active customers get this step immediately (for active releases)
- New customers will inherit this step automatically
- **BR-S07**: Editing template steps after activation only affects customers where step is `pending` and not `overridden`
- **BR-S08**: Customer-specific overrides persist even when template is edited
--- ---

View File

@ -706,10 +706,56 @@ export async function addCustomStep(
type: 'bash' | 'sql' | 'text'; type: 'bash' | 'sql' | 'text';
content: string; content: string;
orderIndex: number; orderIndex: number;
addToTemplate?: boolean; // NEW: Option to add to template
} }
): Promise<CustomerStep> { ): Promise<CustomerStep> {
const { addToTemplate, ...stepData } = data;
// If addToTemplate is true, create template step first
let templateId: number | null = null;
if (addToTemplate) {
const [template] = await db.insert(stepTemplates).values({
...stepData,
releaseId,
}).returning();
templateId = template.id;
// Add to ALL active customers (not just the current one)
const allCustomers = await db.query.customers.findMany({
where: eq(customers.isActive, true),
});
const stepsToInsert = allCustomers.map(customer => ({
releaseId,
customerId: customer.id,
templateId: template.id,
...stepData,
status: 'pending' as const,
isCustom: false, // It's now a template step
isOverridden: false,
}));
if (stepsToInsert.length > 0) {
await db.insert(customerSteps).values(stepsToInsert);
}
// Return the step for the requested customer
const [step] = await db.query.customerSteps.findMany({
where: and(
eq(customerSteps.releaseId, releaseId),
eq(customerSteps.customerId, customerId),
eq(customerSteps.templateId, templateId)
),
});
revalidatePath(`/releases/${releaseId}`);
return step;
}
// Original behavior: custom step for single customer
const [step] = await db.insert(customerSteps).values({ const [step] = await db.insert(customerSteps).values({
...data, ...stepData,
releaseId, releaseId,
customerId, customerId,
templateId: null, templateId: null,
@ -964,6 +1010,143 @@ export function StepCard({ step, ...actions }: StepCardProps) {
} }
``` ```
### 6.3 Step Detail Side Panel (NEW)
```typescript
// components/steps/step-detail-panel.tsx
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { CodeBlock } from './code-block';
interface StepDetailPanelProps {
step: CustomerStep & { template?: StepTemplate; customer: Customer };
isOpen: boolean;
onClose: () => void;
onMarkDone: (id: number, notes?: string) => void;
onSkip: (id: number, reason: string) => void;
onRevert: (id: number, reason?: string) => void;
onOverride: (id: number, content: string) => void;
onResetToTemplate: (id: number) => void;
onEditCustom: (id: number, data: Partial<CustomStepInput>) => void;
onDeleteCustom: (id: number) => void;
}
export function StepDetailPanel({ step, isOpen, onClose, ...actions }: StepDetailPanelProps) {
// Side panel with:
// - Source info (template/custom/overridden)
// - Syntax-highlighted content
// - Action buttons based on source type
// - Execution notes input
// - History timeline
}
```
### 6.4 Add Custom Step Dialog (NEW)
```typescript
// components/steps/add-custom-step-dialog.tsx
interface AddCustomStepDialogProps {
releaseId: number;
customerId: number;
category: 'deploy' | 'verify';
existingSteps: { id: number; name: string; orderIndex: number }[];
isOpen: boolean;
onClose: () => void;
onAdd: (data: CustomStepInput & { addToTemplate: boolean; insertPosition: number }) => void;
}
export function AddCustomStepDialog({ ...props }: AddCustomStepDialogProps) {
// Dialog with:
// - Name, type, content inputs
// - Insert position dropdown (before/after existing steps)
// - "Add to template" checkbox (unchecked by default)
// - Add/Cancel buttons
}
```
### 6.5 Draggable Step List (NEW)
```typescript
// components/steps/draggable-step-list.tsx
import { DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
interface DraggableStepListProps {
steps: (StepTemplate | CustomerStep)[];
onReorder: (orderedIds: number[]) => void;
renderStep: (step: StepTemplate | CustomerStep, index: number) => React.ReactNode;
}
export function DraggableStepList({ steps, onReorder, renderStep }: DraggableStepListProps) {
// Drag-and-drop list using @dnd-kit
// Visual drag handle on each item
// Smooth animations
// Calls onReorder when drop completes
}
```
### 6.6 Code Block with Syntax Highlight (NEW)
```typescript
// components/steps/code-block.tsx
import Prism from 'prismjs';
import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-bash';
import 'prismjs/themes/prism-tomorrow.css';
interface CodeBlockProps {
code: string;
type: 'bash' | 'sql' | 'text';
showLineNumbers?: boolean;
}
export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps) {
// Server-side or client-side syntax highlighting
// Copy to clipboard button
// Line numbers optional
}
```
---
## 6.7 API Endpoints (NEW)
### Reorder Steps Endpoint
```typescript
// app/api/steps/reorder/route.ts
import { NextRequest } from 'next/server';
import { reorderSteps } from '@/lib/actions/step-templates';
export async function POST(request: NextRequest) {
const { releaseId, category, orderedIds } = await request.json();
await reorderSteps(releaseId, category, orderedIds);
return Response.json({ success: true });
}
```
### Get Step Detail Endpoint
```typescript
// app/api/steps/[id]/detail/route.ts
import { NextRequest } from 'next/server';
import { getStepWithDetails } from '@/lib/actions/customer-steps';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const stepId = parseInt(params.id);
const step = await getStepWithDetails(stepId);
return Response.json(step);
}
```
--- ---
## 7. Configuration ## 7. Configuration

88
package-lock.json generated
View File

@ -8,7 +8,11 @@
"name": "my-app", "name": "my-app",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@ -298,6 +302,60 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@drizzle-team/brocli": { "node_modules/@drizzle-team/brocli": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@ -2252,6 +2310,36 @@
} }
} }
}, },
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible": { "node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",

View File

@ -9,7 +9,11 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",

View File

@ -0,0 +1,30 @@
import { NextRequest } from 'next/server';
import { getStepWithDetails } from '@/lib/actions/customer-steps';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const stepId = parseInt(id);
if (isNaN(stepId)) {
return Response.json({ error: 'Invalid step ID' }, { status: 400 });
}
const step = await getStepWithDetails(stepId);
if (!step) {
return Response.json({ error: 'Step not found' }, { status: 404 });
}
return Response.json(step);
} catch (error) {
console.error('Error fetching step details:', error);
return Response.json(
{ error: 'Failed to fetch step details' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,26 @@
import { NextRequest } from 'next/server';
import { reorderSteps } from '@/lib/actions/step-templates';
import { StepCategory } from '@/lib/db/schema';
export async function POST(request: NextRequest) {
try {
const { releaseId, category, orderedIds } = await request.json();
if (!releaseId || !category || !orderedIds || !Array.isArray(orderedIds)) {
return Response.json(
{ error: 'Missing required fields: releaseId, category, orderedIds' },
{ status: 400 }
);
}
await reorderSteps(releaseId, category as StepCategory, orderedIds);
return Response.json({ success: true });
} catch (error) {
console.error('Error reordering steps:', error);
return Response.json(
{ error: 'Failed to reorder steps' },
{ status: 500 }
);
}
}

View File

@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { ArrowLeft, Edit, Play, Archive, Plus, CheckCircle, Circle, SkipForward, RotateCcw } from 'lucide-react'; import { ArrowLeft, Edit, Play, Archive, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -9,6 +9,7 @@ import { Separator } from '@/components/ui/separator';
import { getReleaseById, activateRelease, archiveRelease } from '@/lib/actions/releases'; import { getReleaseById, activateRelease, archiveRelease } from '@/lib/actions/releases';
import { getReleaseStepsGroupedByCluster, getStepStats } from '@/lib/actions/customer-steps'; import { getReleaseStepsGroupedByCluster, getStepStats } from '@/lib/actions/customer-steps';
import { listCustomers } from '@/lib/actions/customers'; import { listCustomers } from '@/lib/actions/customers';
import { ReleaseMatrixClient } from '@/components/releases/release-matrix-client';
interface ReleaseDetailPageProps { interface ReleaseDetailPageProps {
params: Promise<{ params: Promise<{
@ -202,7 +203,7 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
</TabsList> </TabsList>
<TabsContent value="deploy" className="mt-4"> <TabsContent value="deploy" className="mt-4">
<MatrixView <ReleaseMatrixClient
stepsByCluster={stepsByCluster} stepsByCluster={stepsByCluster}
category="deploy" category="deploy"
releaseId={releaseId} releaseId={releaseId}
@ -210,7 +211,7 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
</TabsContent> </TabsContent>
<TabsContent value="verify" className="mt-4"> <TabsContent value="verify" className="mt-4">
<MatrixView <ReleaseMatrixClient
stepsByCluster={stepsByCluster} stepsByCluster={stepsByCluster}
category="verify" category="verify"
releaseId={releaseId} releaseId={releaseId}
@ -221,143 +222,3 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
</div> </div>
); );
} }
interface MatrixViewProps {
stepsByCluster: any;
category: 'deploy' | 'verify';
releaseId: number;
}
function MatrixView({ stepsByCluster, category, releaseId }: MatrixViewProps) {
const clusters = Object.values(stepsByCluster);
if (clusters.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-slate-500">
No customers found. Add customers to see the matrix view.
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{clusters.map((clusterData: any) => {
const customers = Object.values(clusterData.customers);
// Get all unique steps for this category
const allSteps = new Map();
customers.forEach((customer: any) => {
customer.steps
.filter((s: any) => s.category === category)
.forEach((step: any) => {
if (!allSteps.has(step.name)) {
allSteps.set(step.name, step);
}
});
});
const steps = Array.from(allSteps.values()).sort((a: any, b: any) => a.orderIndex - b.orderIndex);
if (steps.length === 0) return null;
return (
<Card key={clusterData.cluster?.id || 'unknown'}>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<span className="w-3 h-3 bg-blue-500 rounded-full"></span>
{clusterData.cluster?.name || 'Unknown Cluster'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 font-medium text-slate-500 w-48">Step</th>
{customers.map((customer: any) => (
<th key={customer.customer.id} className="text-center py-2 px-3 font-medium text-slate-500 min-w-[120px]">
<div>{customer.customer.name}</div>
<div className="text-xs text-slate-400 font-normal">{customer.customer.namespace}</div>
</th>
))}
</tr>
</thead>
<tbody>
{steps.map((step: any, stepIndex: number) => (
<tr key={step.id} className="border-b hover:bg-slate-50">
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400">{stepIndex + 1}.</span>
<div>
<p className="font-medium text-sm">{step.name}</p>
{step.isOverridden && (
<Badge variant="outline" className="text-xs">custom</Badge>
)}
</div>
</div>
</td>
{customers.map((customer: any) => {
const customerStep = customer.steps.find(
(s: any) => s.name === step.name && s.category === category
);
if (!customerStep) return <td key={customer.customer.id} className="py-2 px-3"></td>;
return (
<td key={customer.customer.id} className="py-2 px-3 text-center">
<StepStatusCell step={customerStep} releaseId={releaseId} />
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
function StepStatusCell({ step, releaseId }: { step: any; releaseId: number }) {
const statusIcons = {
pending: <Circle className="w-5 h-5 text-slate-300" />,
done: <CheckCircle className="w-5 h-5 text-green-500" />,
skipped: <SkipForward className="w-5 h-5 text-amber-500" />,
reverted: <RotateCcw className="w-5 h-5 text-red-500" />,
};
async function markDone() {
'use server';
const { markStepDone } = await import('@/lib/actions/customer-steps');
await markStepDone(step.id);
}
async function skipStep(formData: FormData) {
'use server';
const { skipStep } = await import('@/lib/actions/customer-steps');
await skipStep(step.id, formData.get('reason') as string);
}
return (
<div className="flex flex-col items-center gap-1">
<form action={markDone}>
<button type="submit" className="hover:scale-110 transition-transform">
{statusIcons[step.status as keyof typeof statusIcons]}
</button>
</form>
{step.status === 'pending' && (
<form action={skipStep} className="flex gap-1">
<input name="reason" type="hidden" value="Skipped by user" />
<button type="submit" className="text-xs text-slate-400 hover:text-amber-600">
skip
</button>
</form>
)}
</div>
);
}

View File

@ -1,9 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { ArrowLeft, Plus, GripVertical, Trash2 } from 'lucide-react'; import { ArrowLeft, Plus, GripVertical, Trash2, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@ -15,7 +15,23 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useEffect } from 'react'; import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface Step { interface Step {
id: number; id: number;
@ -120,11 +136,25 @@ interface StepListProps {
function StepList({ steps, category, releaseId, onUpdate }: StepListProps) { function StepList({ steps, category, releaseId, onUpdate }: StepListProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [localSteps, setLocalSteps] = useState<Step[]>(steps);
const [isReordering, setIsReordering] = useState(false);
useEffect(() => {
setLocalSteps(steps);
}, [steps]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
async function handleAddStep(formData: FormData) { async function handleAddStep(formData: FormData) {
try { try {
const response = await fetch('/api/steps', { const response = await fetch('/api/steps', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
releaseId, releaseId,
category, category,
@ -153,6 +183,45 @@ function StepList({ steps, category, releaseId, onUpdate }: StepListProps) {
} }
} }
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
setIsReordering(true);
const oldIndex = localSteps.findIndex((s) => s.id === active.id);
const newIndex = localSteps.findIndex((s) => s.id === over.id);
const newSteps = arrayMove(localSteps, oldIndex, newIndex);
setLocalSteps(newSteps);
// Send reorder request to server
try {
const response = await fetch('/api/steps/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
releaseId,
category,
orderedIds: newSteps.map(s => s.id),
}),
});
if (!response.ok) {
// Revert on error
setLocalSteps(steps);
alert('Failed to reorder steps');
}
} catch (error) {
console.error('Reorder error:', error);
setLocalSteps(steps);
} finally {
setIsReordering(false);
onUpdate();
}
}
}
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
@ -195,41 +264,94 @@ function StepList({ steps, category, releaseId, onUpdate }: StepListProps) {
</Dialog> </Dialog>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{steps.length === 0 ? ( {localSteps.length === 0 ? (
<p className="text-slate-500 text-center py-8">No {category} steps defined yet.</p> <p className="text-slate-500 text-center py-8">No {category} steps defined yet.</p>
) : ( ) : (
<div className="space-y-2"> <DndContext
{steps.map((step, index) => ( sensors={sensors}
<div collisionDetection={closestCenter}
key={step.id} onDragEnd={handleDragEnd}
className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg border" >
> <SortableContext
<GripVertical className="w-4 h-4 text-slate-400" /> items={localSteps.map(s => s.id)}
<span className="text-sm text-slate-500 w-6">{index + 1}.</span> strategy={verticalListSortingStrategy}
<div className="flex-1"> >
<div className="flex items-center gap-2"> <div className={`space-y-2 ${isReordering ? 'opacity-50' : ''}`}>
<span className="font-medium">{step.name}</span> {localSteps.map((step, index) => (
<Badge variant="outline" className="text-xs capitalize"> <SortableStepItem
{step.type} key={step.id}
</Badge> step={step}
</div> index={index}
{step.description && ( onDelete={() => handleDeleteStep(step.id)}
<p className="text-sm text-slate-500">{step.description}</p> />
)} ))}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div> </div>
))} </SortableContext>
</div> </DndContext>
)} )}
</CardContent> </CardContent>
</Card> </Card>
); );
} }
interface SortableStepItemProps {
step: Step;
index: number;
onDelete: () => void;
}
function SortableStepItem({ step, index, onDelete }: SortableStepItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-3 p-3 bg-slate-50 rounded-lg border ${
isDragging ? 'shadow-lg ring-2 ring-blue-500' : ''
}`}
>
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing"
>
<GripVertical className="w-4 h-4 text-slate-400" />
</button>
<span className="text-sm text-slate-500 w-6">{index + 1}.</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-400" />
<span className="font-medium">{step.name}</span>
<Badge variant="outline" className="text-xs capitalize">
{step.type}
</Badge>
</div>
{step.description && (
<p className="text-sm text-slate-500">{step.description}</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
}

View File

@ -2,7 +2,7 @@ import Link from 'next/link';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ReleaseCard } from '@/components/releases/release-card'; import { ReleaseList } from '@/components/releases/release-list';
import { listReleases } from '@/lib/actions/releases'; import { listReleases } from '@/lib/actions/releases';
export default async function ReleasesPage() { export default async function ReleasesPage() {
@ -43,45 +43,15 @@ export default async function ReleasesPage() {
</TabsList> </TabsList>
<TabsContent value="active" className="mt-6"> <TabsContent value="active" className="mt-6">
{active.length === 0 ? ( <ReleaseList releases={active} />
<div className="text-center py-12 bg-white rounded-lg border border-dashed border-slate-300">
<p className="text-slate-600">No active releases.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{active.map((release) => (
<ReleaseCard key={release.id} release={release} />
))}
</div>
)}
</TabsContent> </TabsContent>
<TabsContent value="draft" className="mt-6"> <TabsContent value="draft" className="mt-6">
{drafts.length === 0 ? ( <ReleaseList releases={drafts} />
<div className="text-center py-12 bg-white rounded-lg border border-dashed border-slate-300">
<p className="text-slate-600">No draft releases.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{drafts.map((release) => (
<ReleaseCard key={release.id} release={release} />
))}
</div>
)}
</TabsContent> </TabsContent>
<TabsContent value="archived" className="mt-6"> <TabsContent value="archived" className="mt-6">
{archived.length === 0 ? ( <ReleaseList releases={archived} showActions={false} />
<div className="text-center py-12 bg-white rounded-lg border border-dashed border-slate-300">
<p className="text-slate-600">No archived releases.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{archived.map((release) => (
<ReleaseCard key={release.id} release={release} showActions={false} />
))}
</div>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View File

@ -89,7 +89,7 @@ export function ReleaseCard({ release, showActions = true }: ReleaseCardProps) {
} }
return ( return (
<Card> <Card className="flex flex-col h-full">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -170,21 +170,23 @@ export function ReleaseCard({ release, showActions = true }: ReleaseCardProps) {
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-col flex-grow">
{release.versionNumber && ( <div className="flex-grow">
<div className="text-sm text-slate-600 mb-2"> {release.versionNumber && (
<span className="font-medium">Version:</span> {release.versionNumber} <div className="text-sm text-slate-600 mb-2">
</div> <span className="font-medium">Version:</span> {release.versionNumber}
)} </div>
{release.releaseDate && ( )}
<div className="text-sm text-slate-600 mb-2"> {release.releaseDate && (
<span className="font-medium">Release Date:</span>{' '} <div className="text-sm text-slate-600 mb-2">
{new Date(release.releaseDate).toLocaleDateString()} <span className="font-medium">Release Date:</span>{' '}
</div> {new Date(release.releaseDate).toLocaleDateString()}
)} </div>
{release.description && ( )}
<p className="text-sm text-slate-600 line-clamp-2">{release.description}</p> {release.description && (
)} <p className="text-sm text-slate-600 line-clamp-2">{release.description}</p>
)}
</div>
<div className="mt-4 pt-4 border-t"> <div className="mt-4 pt-4 border-t">
<Link href={`/releases/${release.id}`}> <Link href={`/releases/${release.id}`}>
<Button variant="outline" size="sm" className="w-full"> <Button variant="outline" size="sm" className="w-full">

View File

@ -0,0 +1,279 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { Package, Play, Archive, Copy, Edit, Trash2, ChevronDown, ChevronUp, ArrowUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { Release } from '@/lib/db/schema';
interface ReleaseListProps {
releases: Release[];
showActions?: boolean;
}
type SortField = 'name' | 'type' | 'status' | 'createdAt' | 'releaseDate';
type SortDirection = 'asc' | 'desc';
const typeColors: Record<string, string> = {
onboarding: 'bg-purple-100 text-purple-800',
release: 'bg-blue-100 text-blue-800',
hotfix: 'bg-red-100 text-red-800',
};
const statusColors: Record<string, string> = {
draft: 'bg-slate-100 text-slate-800',
active: 'bg-green-100 text-green-800',
archived: 'bg-gray-100 text-gray-800',
};
function getTypeColor(type: string) {
return typeColors[type] || 'bg-slate-100 text-slate-800';
}
function getStatusColor(status: string) {
return statusColors[status] || 'bg-slate-100 text-slate-800';
}
export function ReleaseList({ releases, showActions = true }: ReleaseListProps) {
const [sortField, setSortField] = useState<SortField>('createdAt');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedReleases = [...releases].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'type':
comparison = (a.type || '').localeCompare(b.type || '');
break;
case 'status':
comparison = (a.status || '').localeCompare(b.status || '');
break;
case 'createdAt':
comparison = new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
break;
case 'releaseDate':
const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
comparison = dateA - dateB;
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return <ArrowUpDown className="w-3 h-3 ml-1 text-slate-400" />;
}
return sortDirection === 'asc'
? <ChevronUp className="w-3 h-3 ml-1 text-slate-600" />
: <ChevronDown className="w-3 h-3 ml-1 text-slate-600" />;
};
async function handleActivate(id: number) {
try {
const { activateRelease } = await import('@/lib/actions/releases');
await activateRelease(id);
window.location.reload();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to activate release');
}
}
async function handleArchive(id: number) {
try {
const { archiveRelease } = await import('@/lib/actions/releases');
await archiveRelease(id);
window.location.reload();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to archive release');
}
}
async function handleClone(release: Release) {
try {
const { cloneRelease } = await import('@/lib/actions/releases');
const newName = `${release.name} (Copy)`;
await cloneRelease(release.id, newName);
window.location.reload();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to clone release');
}
}
if (releases.length === 0) {
return (
<div className="text-center py-12 bg-white rounded-lg border border-dashed border-slate-300">
<p className="text-slate-600">No releases found.</p>
</div>
);
}
return (
<div className="bg-white rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]"></TableHead>
<TableHead
className="cursor-pointer hover:bg-slate-50"
onClick={() => handleSort('name')}
>
<div className="flex items-center">
Name
<SortIcon field="name" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-slate-50"
onClick={() => handleSort('type')}
>
<div className="flex items-center">
Type
<SortIcon field="type" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-slate-50"
onClick={() => handleSort('status')}
>
<div className="flex items-center">
Status
<SortIcon field="status" />
</div>
</TableHead>
<TableHead>Version</TableHead>
<TableHead
className="cursor-pointer hover:bg-slate-50"
onClick={() => handleSort('releaseDate')}
>
<div className="flex items-center">
Release Date
<SortIcon field="releaseDate" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-slate-50"
onClick={() => handleSort('createdAt')}
>
<div className="flex items-center">
Created
<SortIcon field="createdAt" />
</div>
</TableHead>
{showActions && <TableHead className="w-[100px]">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{sortedReleases.map((release) => (
<TableRow key={release.id} className="hover:bg-slate-50">
<TableCell>
<div className="w-8 h-8 bg-indigo-100 rounded-lg flex items-center justify-center">
<Package className="w-4 h-4 text-indigo-600" />
</div>
</TableCell>
<TableCell>
<Link
href={`/releases/${release.id}`}
className="font-medium text-slate-900 hover:text-indigo-600"
>
{release.name}
</Link>
{release.description && (
<p className="text-sm text-slate-500 truncate max-w-[200px]">
{release.description}
</p>
)}
</TableCell>
<TableCell>
<Badge className={getTypeColor(release.type || '')} variant="secondary">
{release.type}
</Badge>
</TableCell>
<TableCell>
<Badge className={getStatusColor(release.status || '')} variant="secondary">
{release.status}
</Badge>
</TableCell>
<TableCell className="text-slate-600">
{release.versionNumber || '-'}
</TableCell>
<TableCell className="text-slate-600">
{release.releaseDate
? new Date(release.releaseDate).toLocaleDateString()
: '-'}
</TableCell>
<TableCell className="text-slate-600">
{release.createdAt
? new Date(release.createdAt).toLocaleDateString()
: '-'}
</TableCell>
{showActions && (
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{release.status === 'draft' && (
<DropdownMenuItem onClick={() => handleActivate(release.id)}>
<Play className="w-4 h-4 mr-2" />
Activate
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleClone(release)}>
<Copy className="w-4 h-4 mr-2" />
Clone
</DropdownMenuItem>
{release.status === 'active' && (
<DropdownMenuItem onClick={() => handleArchive(release.id)}>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href={`/releases/${release.id}/edit`} className="flex items-center">
<Edit className="w-4 h-4 mr-2" />
Edit
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,243 @@
'use client';
import { useState } from 'react';
import { Plus, CheckCircle, Circle, SkipForward, RotateCcw, FileText, AlertCircle, Edit } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { StepDetailPanel } from '@/components/steps/step-detail-panel';
import { AddCustomStepDialog } from '@/components/steps/add-custom-step-dialog';
interface ReleaseMatrixClientProps {
stepsByCluster: any;
category: 'deploy' | 'verify';
releaseId: number;
}
const statusIcons = {
pending: <Circle className="w-5 h-5 text-slate-300" />,
done: <CheckCircle className="w-5 h-5 text-green-500" />,
skipped: <SkipForward className="w-5 h-5 text-amber-500" />,
reverted: <RotateCcw className="w-5 h-5 text-red-500" />,
};
export function ReleaseMatrixClient({ stepsByCluster, category, releaseId }: ReleaseMatrixClientProps) {
const [selectedStep, setSelectedStep] = useState<any>(null);
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const clusters = Object.values(stepsByCluster);
const handleStepClick = (step: any, template: any = null) => {
setSelectedStep(step);
setSelectedTemplate(template);
setIsPanelOpen(true);
};
const handleActionComplete = () => {
setRefreshKey(prev => prev + 1);
setIsPanelOpen(false);
// Refresh the page to get updated data
window.location.reload();
};
// Server actions wrapped in async functions
const markStepDone = async (id: number, notes?: string) => {
const { markStepDone } = await import('@/lib/actions/customer-steps');
await markStepDone(id, notes);
handleActionComplete();
};
const skipStep = async (id: number, reason: string) => {
const { skipStep } = await import('@/lib/actions/customer-steps');
await skipStep(id, reason);
handleActionComplete();
};
const markStepReverted = async (id: number, reason?: string) => {
const { markStepReverted } = await import('@/lib/actions/customer-steps');
await markStepReverted(id, reason);
handleActionComplete();
};
const overrideStepContent = async (id: number, content: string) => {
const { overrideStepContent } = await import('@/lib/actions/customer-steps');
await overrideStepContent(id, content);
handleActionComplete();
};
const resetToTemplate = async (id: number) => {
const { resetToTemplate } = await import('@/lib/actions/customer-steps');
await resetToTemplate(id);
handleActionComplete();
};
const editCustomStep = async (id: number, data: any) => {
const { editCustomStep } = await import('@/lib/actions/customer-steps');
await editCustomStep(id, data);
handleActionComplete();
};
const deleteCustomStep = async (id: number) => {
const { deleteCustomStep } = await import('@/lib/actions/customer-steps');
await deleteCustomStep(id);
handleActionComplete();
};
const addCustomStep = async (customerId: number, customerName: string, existingSteps: any[]) => {
// This will be handled by the dialog component
return async (data: any) => {
const { addCustomStep } = await import('@/lib/actions/customer-steps');
await addCustomStep(releaseId, customerId, data);
handleActionComplete();
};
};
if (clusters.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-slate-500">
No customers found. Add customers to see the matrix view.
</CardContent>
</Card>
);
}
return (
<>
<div className="space-y-6">
{clusters.map((clusterData: any) => {
const customers = Object.values(clusterData.customers);
// Get all unique steps for this category, sorted by orderIndex
const allSteps = new Map();
customers.forEach((customer: any) => {
customer.steps
.filter((s: any) => s.category === category)
.forEach((step: any) => {
// Use a combination of name and templateId as key to handle custom steps
const key = step.templateId ? `template-${step.templateId}` : `custom-${step.id}`;
if (!allSteps.has(key)) {
allSteps.set(key, step);
}
});
});
// Sort by orderIndex (handles decimals for mixed ordering)
const steps = Array.from(allSteps.values()).sort((a: any, b: any) => a.orderIndex - b.orderIndex);
if (steps.length === 0) return null;
return (
<Card key={clusterData.cluster?.id || 'unknown'}>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<span className="w-3 h-3 bg-blue-500 rounded-full"></span>
{clusterData.cluster?.name || 'Unknown Cluster'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 font-medium text-slate-500 w-48">Step</th>
{customers.map((customer: any) => (
<th key={customer.customer.id} className="text-center py-2 px-3 font-medium text-slate-500 min-w-[140px]">
<div>{customer.customer.name}</div>
<div className="text-xs text-slate-400 font-normal">{customer.customer.namespace}</div>
<div className="mt-2">
<AddCustomStepDialog
releaseId={releaseId}
customerId={customer.customer.id}
customerName={customer.customer.name}
category={category}
existingSteps={steps.map((s: any) => ({ id: s.id, name: s.name, orderIndex: s.orderIndex }))}
onAdd={async (data) => {
const { addCustomStep } = await import('@/lib/actions/customer-steps');
await addCustomStep(releaseId, customer.customer.id, data);
handleActionComplete();
}}
/>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{steps.map((step: any, stepIndex: number) => (
<tr key={step.id} className="border-b hover:bg-slate-50">
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-6">{stepIndex + 1}.</span>
<div>
<p className="font-medium text-sm">{step.name}</p>
<div className="flex gap-1 mt-1">
{step.isCustom && (
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-200">
<FileText className="w-3 h-3 mr-1" />
custom
</Badge>
)}
{step.isOverridden && (
<Badge variant="outline" className="text-xs bg-amber-50 text-amber-700 border-amber-200">
<AlertCircle className="w-3 h-3 mr-1" />
overridden
</Badge>
)}
</div>
</div>
</div>
</td>
{customers.map((customer: any) => {
// Find the customer step - match by templateId for template steps, or by id for custom steps
const customerStep = customer.steps.find(
(s: any) => {
if (step.templateId) {
return s.templateId === step.templateId && s.category === category;
}
return s.id === step.id && s.category === category;
}
);
if (!customerStep) return <td key={customer.customer.id} className="py-2 px-3"></td>;
return (
<td key={customer.customer.id} className="py-2 px-3 text-center">
<button
onClick={() => handleStepClick(customerStep, step.template)}
className="hover:scale-110 transition-transform"
>
{statusIcons[customerStep.status as keyof typeof statusIcons]}
</button>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
})}
</div>
<StepDetailPanel
step={selectedStep}
template={selectedTemplate}
isOpen={isPanelOpen}
onClose={() => setIsPanelOpen(false)}
onMarkDone={markStepDone}
onSkip={skipStep}
onRevert={markStepReverted}
onOverride={overrideStepContent}
onResetToTemplate={resetToTemplate}
onEditCustom={editCustomStep}
onDeleteCustom={deleteCustomStep}
/>
</>
);
}

View File

@ -0,0 +1,232 @@
'use client';
import { useState } from 'react';
import { Plus, FileText } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
interface ExistingStep {
id: number;
name: string;
orderIndex: number;
}
interface AddCustomStepDialogProps {
releaseId: number;
customerId: number;
customerName: string;
category: 'deploy' | 'verify';
existingSteps: ExistingStep[];
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onAdd: (data: {
name: string;
category: 'deploy' | 'verify';
type: 'bash' | 'sql' | 'text';
content: string;
orderIndex: number;
addToTemplate: boolean;
}) => Promise<void>;
}
export function AddCustomStepDialog({
releaseId,
customerId,
customerName,
category,
existingSteps,
isOpen: controlledIsOpen,
onOpenChange: controlledOnOpenChange,
onAdd,
}: AddCustomStepDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState('');
const [type, setType] = useState<'bash' | 'sql' | 'text'>('bash');
const [content, setContent] = useState('');
const [insertAfterId, setInsertAfterId] = useState<string>('');
const [addToTemplate, setAddToTemplate] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const isControlled = controlledIsOpen !== undefined;
const openState = isControlled ? controlledIsOpen : isOpen;
const setOpenState = isControlled ? controlledOnOpenChange! : setIsOpen;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !content.trim()) return;
setIsSubmitting(true);
// Calculate orderIndex based on insert position
let orderIndex = existingSteps.length;
if (insertAfterId) {
const afterStep = existingSteps.find(s => s.id.toString() === insertAfterId);
if (afterStep) {
// Use decimal to insert between (e.g., if after step with index 1, use 1.5)
const nextStep = existingSteps.find(s => s.orderIndex > afterStep.orderIndex);
if (nextStep) {
orderIndex = (afterStep.orderIndex + nextStep.orderIndex) / 2;
} else {
orderIndex = afterStep.orderIndex + 1;
}
}
} else if (existingSteps.length > 0) {
// Insert at beginning - use negative decimal or 0.5 before first
const firstStep = existingSteps[0];
orderIndex = firstStep.orderIndex / 2;
}
await onAdd({
name: name.trim(),
category,
type,
content: content.trim(),
orderIndex,
addToTemplate,
});
// Reset form
setName('');
setType('bash');
setContent('');
setInsertAfterId('');
setAddToTemplate(false);
setIsSubmitting(false);
setOpenState(false);
};
const typeOptions = [
{ value: 'bash', label: 'Bash Script' },
{ value: 'sql', label: 'SQL' },
{ value: 'text', label: 'Text/Instructions' },
];
return (
<Dialog open={openState} onOpenChange={setOpenState}>
{!isControlled && (
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="w-4 h-4 mr-1" />
Add Custom Step
</Button>
</DialogTrigger>
)}
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Add Custom Step for {customerName}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 mt-4">
<div className="space-y-2">
<Label htmlFor="name">Step Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Run Custom Migration"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type">Type</Label>
<Select value={type} onValueChange={(v) => setType(v as 'bash' | 'sql' | 'text')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{typeOptions.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="position">Insert Position</Label>
<Select value={insertAfterId} onValueChange={setInsertAfterId}>
<SelectTrigger>
<SelectValue placeholder="At the beginning" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">At the beginning</SelectItem>
{existingSteps.map((step) => (
<SelectItem key={step.id} value={step.id.toString()}>
After: {step.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={type === 'bash' ? '#!/bin/bash\necho "Hello World"' : type === 'sql' ? 'SELECT * FROM users;' : 'Enter instructions here...'}
className="font-mono min-h-[200px]"
required
/>
</div>
<div className="flex items-start space-x-3 p-4 bg-slate-50 rounded-lg">
<Checkbox
id="addToTemplate"
checked={addToTemplate}
onCheckedChange={(checked) => setAddToTemplate(checked as boolean)}
/>
<div className="space-y-1">
<Label
htmlFor="addToTemplate"
className="text-sm font-medium cursor-pointer"
>
Add to template (apply to all customers)
</Label>
<p className="text-sm text-slate-500">
If checked, this step will be added to the release template and all customers will receive it.
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => setOpenState(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !name.trim() || !content.trim()}>
{isSubmitting ? 'Adding...' : 'Add Step'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,91 @@
'use client';
import { useEffect, useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface CodeBlockProps {
code: string;
type: 'bash' | 'sql' | 'text';
showLineNumbers?: boolean;
}
export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps) {
const [highlighted, setHighlighted] = useState<string>('');
const [copied, setCopied] = useState(false);
useEffect(() => {
async function highlight() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Prism = require('prismjs');
// Load language components dynamically
if (type === 'sql') {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('prismjs/components/prism-sql');
} else if (type === 'bash') {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('prismjs/components/prism-bash');
}
const language = type === 'text' ? 'text' : type;
const grammar = Prism.languages[language] || Prism.languages.text;
const highlightedCode = Prism.highlight(code, grammar, language);
setHighlighted(highlightedCode);
}
highlight();
}, [code, type]);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const lines = code.split('\n');
return (
<div className="relative group">
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
<pre className="rounded-lg bg-slate-900 p-4 overflow-x-auto text-sm">
<code className="language-{type}">
{showLineNumbers ? (
<table className="border-collapse">
<tbody>
{lines.map((line, i) => (
<tr key={i}>
<td className="text-slate-500 text-right pr-4 select-none w-12">
{i + 1}
</td>
<td
className="text-slate-100"
dangerouslySetInnerHTML={{
__html: highlighted
? highlighted.split('\n')[i] || ''
: line
}}
/>
</tr>
))}
</tbody>
</table>
) : (
<span dangerouslySetInnerHTML={{ __html: highlighted || code }} />
)}
</code>
</pre>
</div>
);
}

View File

@ -0,0 +1,362 @@
'use client';
import { useState } from 'react';
import { X, RotateCcw, Pencil, Trash2, FileText, AlertCircle, CheckCircle, Check, SkipForward, Copy } from 'lucide-react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { CodeBlock } from './code-block';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface StepDetailPanelProps {
step: any;
template: any;
isOpen: boolean;
onClose: () => void;
onMarkDone: (id: number, notes?: string) => Promise<void>;
onSkip: (id: number, reason: string) => Promise<void>;
onRevert: (id: number, reason?: string) => Promise<void>;
onOverride: (id: number, content: string) => Promise<void>;
onResetToTemplate: (id: number) => Promise<void>;
onEditCustom?: (id: number, data: any) => Promise<void>;
onDeleteCustom?: (id: number) => Promise<void>;
}
const statusIcons = {
pending: <div className="w-5 h-5 rounded-full border-2 border-slate-300" />,
done: <CheckCircle className="w-5 h-5 text-green-500" />,
skipped: <SkipForward className="w-5 h-5 text-amber-500" />,
reverted: <RotateCcw className="w-5 h-5 text-red-500" />,
};
const statusLabels = {
pending: 'Pending',
done: 'Done',
skipped: 'Skipped',
reverted: 'Reverted',
};
const typeLabels = {
bash: 'Bash Script',
sql: 'SQL',
text: 'Text',
};
export function StepDetailPanel({
step,
template,
isOpen,
onClose,
onMarkDone,
onSkip,
onRevert,
onOverride,
onResetToTemplate,
onEditCustom,
onDeleteCustom,
}: StepDetailPanelProps) {
const [notes, setNotes] = useState(step?.notes || '');
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(step?.content || '');
const [showOriginal, setShowOriginal] = useState(false);
const [skipReason, setSkipReason] = useState('');
const [isSkipping, setIsSkipping] = useState(false);
if (!step) return null;
const isCustom = step.isCustom;
const isOverridden = step.isOverridden;
const hasTemplate = !!step.templateId;
const handleMarkDone = async () => {
await onMarkDone(step.id, notes);
onClose();
};
const handleSkip = async () => {
if (!skipReason.trim()) return;
await onSkip(step.id, skipReason);
onClose();
};
const handleRevert = async () => {
await onRevert(step.id, notes);
onClose();
};
const handleOverride = async () => {
await onOverride(step.id, editContent);
setIsEditing(false);
};
const handleResetToTemplate = async () => {
await onResetToTemplate(step.id);
onClose();
};
const handleDeleteCustom = async () => {
if (!onDeleteCustom) return;
if (confirm('Are you sure you want to delete this custom step?')) {
await onDeleteCustom(step.id);
onClose();
}
};
const renderSourceBadge = () => {
if (isCustom) {
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-purple-100 text-purple-800">
<FileText className="w-3 h-3 mr-1" />
Custom Step
</Badge>
{onEditCustom && onDeleteCustom && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => setIsEditing(true)}>
<Pencil className="w-3 h-3 mr-1" />
Edit
</Button>
<Button variant="ghost" size="sm" className="text-red-600" onClick={handleDeleteCustom}>
<Trash2 className="w-3 h-3 mr-1" />
Delete
</Button>
</div>
)}
</div>
);
}
if (isOverridden) {
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-amber-100 text-amber-800">
<AlertCircle className="w-3 h-3 mr-1" />
Overridden from Template
</Badge>
<Button variant="ghost" size="sm" onClick={() => setShowOriginal(!showOriginal)}>
{showOriginal ? 'Hide Original' : 'View Original'}
</Button>
<Button variant="ghost" size="sm" onClick={handleResetToTemplate}>
<RotateCcw className="w-3 h-3 mr-1" />
Reset to Template
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-blue-100 text-blue-800">
<FileText className="w-3 h-3 mr-1" />
From Template
</Badge>
<Button variant="ghost" size="sm" onClick={() => setIsEditing(true)}>
<Pencil className="w-3 h-3 mr-1" />
Override Content
</Button>
</div>
);
};
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent className="w-[600px] sm:max-w-[600px]">
<SheetHeader className="px-6">
<div className="flex items-start justify-between">
<div>
<SheetTitle className="text-xl mb-2">{step.name}</SheetTitle>
<div className="flex items-center gap-2 text-sm text-slate-500">
<span>{step.customer?.name}</span>
<span></span>
<span>{step.customer?.cluster?.name}</span>
<span></span>
<span>{step.customer?.namespace}</span>
</div>
</div>
</div>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-180px)] mt-6 px-6">
<div className="space-y-6">
{/* Status & Type */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{statusIcons[step.status as keyof typeof statusIcons]}
<span className="font-medium">{statusLabels[step.status as keyof typeof statusLabels]}</span>
</div>
<Badge variant="outline">{typeLabels[step.type as keyof typeof typeLabels]}</Badge>
<Badge variant="outline" className="capitalize">{step.category}</Badge>
</div>
<Separator />
{/* Source Info */}
<div>
<label className="text-sm font-medium text-slate-500 mb-2 block">Source</label>
{renderSourceBadge()}
</div>
{/* Content */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-500">Content</label>
{!isEditing && (
<Button
variant="ghost"
size="sm"
onClick={() => navigator.clipboard.writeText(step.content)}
className="h-7 px-2 text-slate-500 hover:text-slate-700"
>
<Copy className="w-3.5 h-3.5 mr-1" />
Copy
</Button>
)}
</div>
{isEditing ? (
<div className="space-y-3">
<Textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="font-mono min-h-[200px]"
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleOverride}>
<Check className="w-4 h-4 mr-1" />
Save
</Button>
<Button size="sm" variant="outline" onClick={() => {
setIsEditing(false);
setEditContent(step.content);
}}>
Cancel
</Button>
</div>
</div>
) : (
<CodeBlock code={step.content} type={step.type} />
)}
{/* Show original content if overridden */}
{showOriginal && template && (
<div className="mt-4">
<label className="text-sm font-medium text-slate-500 mb-2 block">Original Template Content</label>
<CodeBlock code={template.content} type={template.type} />
</div>
)}
</div>
<Separator />
{/* Execution Section */}
{step.status !== 'done' && step.status !== 'skipped' && (
<div className="space-y-3">
<label className="text-sm font-medium text-slate-500 block">Execution Notes</label>
<Textarea
placeholder="Add notes about this execution..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="min-h-[80px]"
/>
{isSkipping ? (
<div className="space-y-2">
<Textarea
placeholder="Reason for skipping..."
value={skipReason}
onChange={(e) => setSkipReason(e.target.value)}
className="min-h-[60px]"
/>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleSkip} disabled={!skipReason.trim()}>
Confirm Skip
</Button>
<Button size="sm" variant="ghost" onClick={() => setIsSkipping(false)}>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex gap-2">
<Button onClick={handleMarkDone}>
<Check className="w-4 h-4 mr-1" />
Mark as Done
</Button>
<Button variant="outline" onClick={() => setIsSkipping(true)}>
<SkipForward className="w-4 h-4 mr-1" />
Skip
</Button>
</div>
)}
</div>
)}
{step.status === 'done' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="w-5 h-5" />
<span className="font-medium">Completed</span>
{step.executedAt && (
<span className="text-sm text-slate-500">
at {new Date(step.executedAt).toLocaleString()}
</span>
)}
</div>
{step.notes && (
<div className="bg-slate-50 p-3 rounded-lg">
<label className="text-sm font-medium text-slate-500">Notes</label>
<p className="text-slate-700 mt-1">{step.notes}</p>
</div>
)}
<Button variant="outline" onClick={handleRevert}>
<RotateCcw className="w-4 h-4 mr-1" />
Revert to Pending
</Button>
</div>
)}
{step.status === 'skipped' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-amber-600">
<SkipForward className="w-5 h-5" />
<span className="font-medium">Skipped</span>
</div>
{step.skipReason && (
<div className="bg-amber-50 p-3 rounded-lg border border-amber-200">
<label className="text-sm font-medium text-amber-700">Skip Reason</label>
<p className="text-slate-700 mt-1">{step.skipReason}</p>
</div>
)}
</div>
)}
{/* History */}
{(step.executedAt || step.updatedAt) && (
<>
<Separator />
<div>
<label className="text-sm font-medium text-slate-500 mb-2 block">History</label>
<div className="space-y-1 text-sm text-slate-500">
<p> Created: {new Date(step.createdAt).toLocaleString()}</p>
{step.isOverridden && <p> Content overridden</p>}
{step.executedAt && <p> Executed: {new Date(step.executedAt).toLocaleString()}</p>}
{step.status === 'skipped' && <p> Skipped: {new Date(step.updatedAt).toLocaleString()}</p>}
</div>
</div>
</>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<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",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

143
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<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",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -17,6 +17,7 @@ export type CustomStepInput = {
type: StepType; type: StepType;
content: string; content: string;
orderIndex: number; orderIndex: number;
addToTemplate?: boolean;
}; };
export async function getCustomerSteps(releaseId: number, customerId: number) { export async function getCustomerSteps(releaseId: number, customerId: number) {
@ -49,8 +50,53 @@ export async function addCustomStep(
customerId: number, customerId: number,
data: CustomStepInput data: CustomStepInput
) { ) {
const { addToTemplate, ...stepData } = data;
// If addToTemplate is true, create template step first
if (addToTemplate) {
const { stepTemplates } = await import('@/lib/db/schema');
const [template] = await db.insert(stepTemplates).values({
...stepData,
releaseId,
description: null,
}).returning();
// Add to ALL active customers
const allCustomers = await db.query.customers.findMany({
where: eq(customers.isActive, true),
});
const stepsToInsert = allCustomers.map(customer => ({
releaseId,
customerId: customer.id,
templateId: template.id,
...stepData,
status: 'pending' as const,
isCustom: false,
isOverridden: false,
}));
if (stepsToInsert.length > 0) {
await db.insert(customerSteps).values(stepsToInsert);
}
// Return the step for the requested customer
const [step] = await db.query.customerSteps.findMany({
where: and(
eq(customerSteps.releaseId, releaseId),
eq(customerSteps.customerId, customerId),
eq(customerSteps.templateId, template.id)
),
});
revalidatePath(`/releases/${releaseId}`);
return step;
}
// Original behavior: custom step for single customer
const [step] = await db.insert(customerSteps).values({ const [step] = await db.insert(customerSteps).values({
...data, ...stepData,
releaseId, releaseId,
customerId, customerId,
templateId: null, templateId: null,
@ -145,6 +191,39 @@ export async function deleteCustomStep(stepId: number) {
revalidatePath(`/releases/${step.releaseId}`); revalidatePath(`/releases/${step.releaseId}`);
} }
export async function editCustomStep(
stepId: number,
data: Partial<Omit<CustomStepInput, 'addToTemplate'>>
) {
const step = await db.query.customerSteps.findFirst({
where: eq(customerSteps.id, stepId),
});
if (!step) throw new Error('Step not found');
if (!step.isCustom) throw new Error('Cannot edit non-custom steps directly; use override instead');
const [updated] = await db
.update(customerSteps)
.set({ ...data, updatedAt: new Date() })
.where(eq(customerSteps.id, stepId))
.returning();
revalidatePath(`/releases/${step.releaseId}`);
return updated;
}
export async function getStepWithDetails(stepId: number) {
return db.query.customerSteps.findFirst({
where: eq(customerSteps.id, stepId),
with: {
customer: {
with: { cluster: true },
},
template: true,
},
});
}
export async function getReleaseStepsGroupedByCluster(releaseId: number) { export async function getReleaseStepsGroupedByCluster(releaseId: number) {
const steps = await db.query.customerSteps.findMany({ const steps = await db.query.customerSteps.findMany({
where: eq(customerSteps.releaseId, releaseId), where: eq(customerSteps.releaseId, releaseId),