feat: complete the main features
This commit is contained in:
parent
d95f11dd79
commit
a7298fe5b8
109
docs/FSD.md
109
docs/FSD.md
|
|
@ -212,11 +212,21 @@ Customer-specific step instance (actual execution unit).
|
|||
| ID | Feature | Description |
|
||||
|----|---------|-------------|
|
||||
| FCS-001 | View Inherited Steps | See template steps applied to customer |
|
||||
| FCS-002 | Override Step Content | Modify step content for specific customer |
|
||||
| FCS-003 | Add Custom Step | Add step only 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; option to "Add to template" (applies to all customers) |
|
||||
| FCS-004 | Skip Step | Mark step as skipped with mandatory reason |
|
||||
| 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
|
||||
|
||||
|
|
@ -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] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Category: Deploy | Type: SQL │
|
||||
│ Source: Template (edited for this customer) │
|
||||
│ Customer: customer-a (prod-us/cust-a-prod) │
|
||||
│ Add Custom Step for customer-a [X] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 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" ▼] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Execution Notes: │ │
|
||||
│ │ [Optional: Add notes about this execution... ] │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ ☐ Add to template (apply to all customers) │
|
||||
│ │
|
||||
│ [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
|
||||
|
||||
### 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-S03**: Skipping requires mandatory reason
|
||||
- **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';
|
||||
content: string;
|
||||
orderIndex: number;
|
||||
addToTemplate?: boolean; // NEW: Option to add to template
|
||||
}
|
||||
): 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({
|
||||
...data,
|
||||
...stepData,
|
||||
releaseId,
|
||||
customerId,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@
|
|||
"name": "my-app",
|
||||
"version": "0.1.0",
|
||||
"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-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
|
@ -298,6 +302,60 @@
|
|||
"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": {
|
||||
"version": "0.10.2",
|
||||
"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": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"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-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@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 { 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { getReleaseStepsGroupedByCluster, getStepStats } from '@/lib/actions/customer-steps';
|
||||
import { listCustomers } from '@/lib/actions/customers';
|
||||
import { ReleaseMatrixClient } from '@/components/releases/release-matrix-client';
|
||||
|
||||
interface ReleaseDetailPageProps {
|
||||
params: Promise<{
|
||||
|
|
@ -202,7 +203,7 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="deploy" className="mt-4">
|
||||
<MatrixView
|
||||
<ReleaseMatrixClient
|
||||
stepsByCluster={stepsByCluster}
|
||||
category="deploy"
|
||||
releaseId={releaseId}
|
||||
|
|
@ -210,7 +211,7 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="verify" className="mt-4">
|
||||
<MatrixView
|
||||
<ReleaseMatrixClient
|
||||
stepsByCluster={stepsByCluster}
|
||||
category="verify"
|
||||
releaseId={releaseId}
|
||||
|
|
@ -221,143 +222,3 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
|
|||
</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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
|
@ -15,7 +15,23 @@ import {
|
|||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 {
|
||||
id: number;
|
||||
|
|
@ -120,11 +136,25 @@ interface StepListProps {
|
|||
|
||||
function StepList({ steps, category, releaseId, onUpdate }: StepListProps) {
|
||||
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) {
|
||||
try {
|
||||
const response = await fetch('/api/steps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
releaseId,
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
|
|
@ -195,19 +264,77 @@ function StepList({ steps, category, releaseId, onUpdate }: StepListProps) {
|
|||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{steps.length === 0 ? (
|
||||
{localSteps.length === 0 ? (
|
||||
<p className="text-slate-500 text-center py-8">No {category} steps defined yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={localSteps.map(s => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className={`space-y-2 ${isReordering ? 'opacity-50' : ''}`}>
|
||||
{localSteps.map((step, index) => (
|
||||
<SortableStepItem
|
||||
key={step.id}
|
||||
className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg border"
|
||||
step={step}
|
||||
index={index}
|
||||
onDelete={() => handleDeleteStep(step.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</CardContent>
|
||||
</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}
|
||||
|
|
@ -221,15 +348,10 @@ function StepList({ steps, category, releaseId, onUpdate }: StepListProps) {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600"
|
||||
onClick={() => handleDeleteStep(step.id)}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,349 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, RotateCcw, Pencil, Trash2, FileText, AlertCircle, CheckCircle, Check, SkipForward } 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>
|
||||
<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">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* 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>
|
||||
<label className="text-sm font-medium text-slate-500 mb-2 block">Content</label>
|
||||
|
||||
{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;
|
||||
content: string;
|
||||
orderIndex: number;
|
||||
addToTemplate?: boolean;
|
||||
};
|
||||
|
||||
export async function getCustomerSteps(releaseId: number, customerId: number) {
|
||||
|
|
@ -49,8 +50,53 @@ export async function addCustomStep(
|
|||
customerId: number,
|
||||
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({
|
||||
...data,
|
||||
...stepData,
|
||||
releaseId,
|
||||
customerId,
|
||||
templateId: null,
|
||||
|
|
@ -145,6 +191,39 @@ export async function deleteCustomStep(stepId: number) {
|
|||
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) {
|
||||
const steps = await db.query.customerSteps.findMany({
|
||||
where: eq(customerSteps.releaseId, releaseId),
|
||||
|
|
|
|||
Loading…
Reference in New Issue