Compare commits
3 Commits
d95f11dd79
...
d05edeba5a
| Author | SHA1 | Date |
|---|---|---|
|
|
d05edeba5a | |
|
|
a40e8ebd9c | |
|
|
a7298fe5b8 |
109
docs/FSD.md
109
docs/FSD.md
|
|
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
185
docs/TSD.md
185
docs/TSD.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue