Compare commits
3 Commits
f006552ac1
...
f41e6724cd
| Author | SHA1 | Date |
|---|---|---|
|
|
f41e6724cd | |
|
|
3e10f1ee9f | |
|
|
445d7122aa |
105
docs/FSD.md
105
docs/FSD.md
|
|
@ -191,11 +191,12 @@ Customer-specific step instance (actual execution unit).
|
||||||
|----|---------|-------------|
|
|----|---------|-------------|
|
||||||
| FR-001 | Create Release | Create with type, name, description, version/date for release type |
|
| FR-001 | Create Release | Create with type, name, description, version/date for release type |
|
||||||
| FR-002 | Release Lifecycle | Draft → Active → Archived workflow |
|
| FR-002 | Release Lifecycle | Draft → Active → Archived workflow |
|
||||||
| FR-003 | Activate Release | Copies template steps to all active customers |
|
| FR-003 | Activate Release | Copies template steps to selected customers (defaults to all, with option to exclude) |
|
||||||
| FR-004 | Clone Release | Use existing release as template for new one |
|
| FR-004 | Clone Release | Use existing release as template for new one |
|
||||||
| FR-005 | Archive Release | Archive completed/abandoned releases |
|
| FR-005 | Archive Release | Archive completed/abandoned releases |
|
||||||
| FR-006 | View Release Dashboard | See progress across all customers and clusters |
|
| FR-006 | View Release Dashboard | See progress across all customers and clusters |
|
||||||
| FR-007 | Filter Dashboard | Filter by cluster to focus on specific infrastructure |
|
| FR-007 | Filter Dashboard | Filter by cluster to focus on specific infrastructure |
|
||||||
|
| FR-008 | Incremental Customer Add | Add customers to an active release after initial activation |
|
||||||
|
|
||||||
### 4.4 Step Management (Template Layer)
|
### 4.4 Step Management (Template Layer)
|
||||||
|
|
||||||
|
|
@ -489,6 +490,62 @@ The side panel opens when clicking a step cell in the matrix view, showing custo
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 5.9 Activate Release - Customer Selection Dialog
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Activate Release: v2.5.0 [X] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Select customers to include in this release: │
|
||||||
|
│ │
|
||||||
|
│ [Search customers...] [Select All] │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ 📁 prod-us (3 customers) │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ ☑️ customer-a namespace: cust-a-prod │
|
||||||
|
│ ☑️ customer-b namespace: cust-b-prod │
|
||||||
|
│ ☐ customer-c namespace: cust-c-prod [excluded] │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ 📁 prod-eu (2 customers) │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ ☑️ customer-d namespace: cust-d-prod │
|
||||||
|
│ ☑️ customer-e namespace: cust-e-prod │
|
||||||
|
│ │
|
||||||
|
│ Selected: 4 of 5 customers │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Activate] │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.10 Release Matrix - Add Customer Button
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Release: v2.5.0 (Regular Release) │
|
||||||
|
│ Type: release | Status: Active | Date: 2024-01-15 │
|
||||||
|
│ [Deploy Tab] [Verify Tab] [Settings] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Filter: [All Clusters ▼] [All Status ▼] [+ Add Customer] │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ 📁 prod-us │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ ┌──────────┬─────────────────┬─────────────────┬────────────┐ │
|
||||||
|
│ │ Step │ customer-a │ customer-b │ customer-c │ │
|
||||||
|
│ │ │ (cust-a-prod) │ (cust-b-prod) │ (ADD ➕) │ │
|
||||||
|
│ ├──────────┼─────────────────┼─────────────────┼────────────┤ │
|
||||||
|
│ │ 1. Deploy│ ✅ Done │ ✅ Done │ │ │
|
||||||
|
│ │ 2. SQL │ ✅ Done │ ⚠️ Overridden │ │ │
|
||||||
|
│ └──────────┴─────────────────┴─────────────────┴────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Legend: ✅ Done | 🔄 Pending | ⏸️ Skipped | ⚠️ Custom/Overridden │
|
||||||
|
│ ➕ Click to add customer to release │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Business Rules
|
## 6. Business Rules
|
||||||
|
|
@ -504,10 +561,13 @@ The side panel opens when clicking a step cell in the matrix view, showing custo
|
||||||
- **BR-M03**: Soft delete only; maintain history for audit
|
- **BR-M03**: Soft delete only; maintain history for audit
|
||||||
|
|
||||||
### 6.3 Release Rules
|
### 6.3 Release Rules
|
||||||
- **BR-R01**: When release is activated, steps are created for ALL active customers across ALL clusters
|
- **BR-R01**: When release is activated, steps are created for SELECTED active customers (default: all, with ability to exclude)
|
||||||
- **BR-R02**: Editing template step only affects customers where step is `pending`
|
- **BR-R02**: Editing template step only affects customers where step is `pending`
|
||||||
- **BR-R03**: Once step is marked `done`, content is locked (prevent accidental changes)
|
- **BR-R03**: Once step is marked `done`, content is locked (prevent accidental changes)
|
||||||
- **BR-R04**: Reverting a step sets status to `reverted` but preserves history
|
- **BR-R04**: Reverting a step sets status to `reverted` but preserves history
|
||||||
|
- **BR-R05**: Additional customers can be added to an active release (incremental add)
|
||||||
|
- **BR-R06**: Only customers not already in the release can be added incrementally
|
||||||
|
- **BR-R07**: Incrementally added customers receive the current template steps (including any edits made after initial activation)
|
||||||
|
|
||||||
### 6.4 Step Rules
|
### 6.4 Step Rules
|
||||||
- **BR-S01**: Custom steps (is_custom=true) don't affect other customers or template (unless "Add to template" is checked)
|
- **BR-S01**: Custom steps (is_custom=true) don't affect other customers or template (unless "Add to template" is checked)
|
||||||
|
|
@ -584,6 +644,47 @@ The side panel opens when clicking a step cell in the matrix view, showing custo
|
||||||
4. Verify and archive
|
4. Verify and archive
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 7.4 Selective Activation & Incremental Add
|
||||||
|
|
||||||
|
**Scenario 1: Staged Release Deployment**
|
||||||
|
```
|
||||||
|
1. Create "release" type Release
|
||||||
|
└─→ Add deploy and verify steps
|
||||||
|
|
||||||
|
2. Activate with Customer Selection
|
||||||
|
└─→ Dialog shows all customers (grouped by cluster)
|
||||||
|
└─→ All customers pre-selected by default
|
||||||
|
└─→ Uncheck customers to exclude (e.g., pilot group only)
|
||||||
|
└─→ Click Activate
|
||||||
|
|
||||||
|
3. Monitor pilot group progress
|
||||||
|
|
||||||
|
4. Add remaining customers incrementally
|
||||||
|
└─→ Go to Release Matrix view
|
||||||
|
└─→ Click "Add Customer" button
|
||||||
|
└─→ Select customers to add
|
||||||
|
└─→ New customers receive current template steps
|
||||||
|
|
||||||
|
5. Continue until all customers complete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario 2: New Customer with Updated Onboarding**
|
||||||
|
```
|
||||||
|
1. Original onboarding release (v1) applied to all existing customers
|
||||||
|
|
||||||
|
2. System evolves, onboarding template changes
|
||||||
|
|
||||||
|
3. Clone v1 → Create onboarding v2 with updated steps
|
||||||
|
|
||||||
|
4. New customer joins
|
||||||
|
|
||||||
|
5. Activate onboarding v2
|
||||||
|
└─→ Exclude all existing customers (they have v1)
|
||||||
|
└─→ Include only the new customer
|
||||||
|
|
||||||
|
6. New customer completes onboarding v2
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Data Display Requirements
|
## 8. Data Display Requirements
|
||||||
|
|
|
||||||
170
docs/TSD.md
170
docs/TSD.md
|
|
@ -485,7 +485,10 @@ export async function updateRelease(
|
||||||
return release;
|
return release;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function activateRelease(id: number): Promise<void> {
|
export async function activateRelease(
|
||||||
|
id: number,
|
||||||
|
customerIds?: number[]
|
||||||
|
): Promise<void> {
|
||||||
const release = await db.query.releases.findFirst({
|
const release = await db.query.releases.findFirst({
|
||||||
where: eq(releases.id, id),
|
where: eq(releases.id, id),
|
||||||
with: { templates: true },
|
with: { templates: true },
|
||||||
|
|
@ -494,12 +497,20 @@ export async function activateRelease(id: number): Promise<void> {
|
||||||
if (!release) throw new Error('Release not found');
|
if (!release) throw new Error('Release not found');
|
||||||
if (release.status !== 'draft') throw new Error('Release is not in draft status');
|
if (release.status !== 'draft') throw new Error('Release is not in draft status');
|
||||||
|
|
||||||
const activeCustomers = await db.query.customers.findMany({
|
// If customerIds not provided, use all active customers (backward compatible)
|
||||||
where: eq(customers.isActive, true),
|
const targetCustomers = customerIds
|
||||||
});
|
? await db.query.customers.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(customers.isActive, true),
|
||||||
|
inArray(customers.id, customerIds)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: await db.query.customers.findMany({
|
||||||
|
where: eq(customers.isActive, true),
|
||||||
|
});
|
||||||
|
|
||||||
// Create customer steps from templates
|
// Create customer steps from templates
|
||||||
const customerStepsToInsert = activeCustomers.flatMap(customer =>
|
const customerStepsToInsert = targetCustomers.flatMap(customer =>
|
||||||
release.templates.map(template => ({
|
release.templates.map(template => ({
|
||||||
releaseId: id,
|
releaseId: id,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
|
|
@ -527,6 +538,64 @@ export async function activateRelease(id: number): Promise<void> {
|
||||||
revalidatePath(`/releases/${id}`);
|
revalidatePath(`/releases/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addCustomersToRelease(
|
||||||
|
releaseId: number,
|
||||||
|
customerIds: number[]
|
||||||
|
): Promise<void> {
|
||||||
|
const release = await db.query.releases.findFirst({
|
||||||
|
where: eq(releases.id, releaseId),
|
||||||
|
with: { templates: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!release) throw new Error('Release not found');
|
||||||
|
if (release.status !== 'active') throw new Error('Release must be active to add customers');
|
||||||
|
|
||||||
|
// Get existing customer IDs in this release
|
||||||
|
const existingSteps = await db.query.customerSteps.findMany({
|
||||||
|
where: eq(customerSteps.releaseId, releaseId),
|
||||||
|
columns: { customerId: true },
|
||||||
|
});
|
||||||
|
const existingCustomerIds = new Set(existingSteps.map(s => s.customerId));
|
||||||
|
|
||||||
|
// Filter out customers already in the release
|
||||||
|
const newCustomerIds = customerIds.filter(id => !existingCustomerIds.has(id));
|
||||||
|
|
||||||
|
if (newCustomerIds.length === 0) {
|
||||||
|
throw new Error('All selected customers are already in this release');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the new customers
|
||||||
|
const newCustomers = await db.query.customers.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(customers.isActive, true),
|
||||||
|
inArray(customers.id, newCustomerIds)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create customer steps from current templates
|
||||||
|
const customerStepsToInsert = newCustomers.flatMap(customer =>
|
||||||
|
release.templates.map(template => ({
|
||||||
|
releaseId: releaseId,
|
||||||
|
customerId: customer.id,
|
||||||
|
templateId: template.id,
|
||||||
|
name: template.name,
|
||||||
|
category: template.category,
|
||||||
|
type: template.type,
|
||||||
|
content: template.content,
|
||||||
|
orderIndex: template.orderIndex,
|
||||||
|
status: 'pending' as const,
|
||||||
|
isCustom: false,
|
||||||
|
isOverridden: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (customerStepsToInsert.length > 0) {
|
||||||
|
await db.insert(customerSteps).values(customerStepsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/releases/${releaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function archiveRelease(id: number): Promise<void> {
|
export async function archiveRelease(id: number): Promise<void> {
|
||||||
await db.update(releases)
|
await db.update(releases)
|
||||||
.set({ status: 'archived', updatedAt: new Date() })
|
.set({ status: 'archived', updatedAt: new Date() })
|
||||||
|
|
@ -1110,9 +1179,78 @@ export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 6.7 Activate Release Dialog (NEW)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/releases/activate-release-dialog.tsx
|
||||||
|
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface CustomerWithCluster {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
cluster: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivateReleaseDialogProps {
|
||||||
|
releaseId: number;
|
||||||
|
releaseName: string;
|
||||||
|
customers: CustomerWithCluster[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onActivate: (customerIds: number[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivateReleaseDialog({
|
||||||
|
releaseId,
|
||||||
|
releaseName,
|
||||||
|
customers,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onActivate
|
||||||
|
}: ActivateReleaseDialogProps) {
|
||||||
|
// Dialog for selecting customers before activation
|
||||||
|
// Groups customers by cluster
|
||||||
|
// Select All / Deselect All buttons
|
||||||
|
// Shows count: "Selected X of Y customers"
|
||||||
|
// Activate button (disabled if no customers selected)
|
||||||
|
// Cancel button
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.8 Add Customer to Release Dialog (NEW)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/releases/add-customer-dialog.tsx
|
||||||
|
|
||||||
|
interface AddCustomerDialogProps {
|
||||||
|
releaseId: number;
|
||||||
|
releaseName: string;
|
||||||
|
// Customers not currently in the release
|
||||||
|
availableCustomers: CustomerWithCluster[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (customerIds: number[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddCustomerDialog({ ...props }: AddCustomerDialogProps) {
|
||||||
|
// Dialog for adding customers to an active release
|
||||||
|
// Shows only customers NOT already in the release
|
||||||
|
// Multi-select with checkboxes
|
||||||
|
// Grouped by cluster
|
||||||
|
// Add button (disabled if none selected)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6.7 API Endpoints (NEW)
|
## 6.9 API Endpoints
|
||||||
|
|
||||||
### Reorder Steps Endpoint
|
### Reorder Steps Endpoint
|
||||||
|
|
||||||
|
|
@ -1147,6 +1285,26 @@ export async function GET(
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Add Customers to Release Endpoint
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/releases/[id]/add-customers/route.ts
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { addCustomersToRelease } from '@/lib/actions/releases';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
const releaseId = parseInt(params.id);
|
||||||
|
const { customerIds } = await request.json();
|
||||||
|
|
||||||
|
await addCustomersToRelease(releaseId, customerIds);
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Configuration
|
## 7. Configuration
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ interface EditClusterPageProps {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function EditClusterPage({ params }: EditClusterPageProps) {
|
export default async function EditClusterPage({ params }: EditClusterPageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cluster = await getClusterById(parseInt(id));
|
const cluster = await getClusterById(parseInt(id));
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ interface ClusterDetailPageProps {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function ClusterDetailPage({ params }: ClusterDetailPageProps) {
|
export default async function ClusterDetailPage({ params }: ClusterDetailPageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cluster = await getClusterWithCustomers(parseInt(id));
|
const cluster = await getClusterWithCustomers(parseInt(id));
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ interface EditCustomerPageProps {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function EditCustomerPage({ params }: EditCustomerPageProps) {
|
export default async function EditCustomerPage({ params }: EditCustomerPageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const [customer, clusters] = await Promise.all([
|
const [customer, clusters] = await Promise.all([
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ interface CustomerDetailPageProps {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function CustomerDetailPage({ params }: CustomerDetailPageProps) {
|
export default async function CustomerDetailPage({ params }: CustomerDetailPageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const customer = await getCustomerById(parseInt(id));
|
const customer = await getCustomerById(parseInt(id));
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { notFound } from 'next/navigation';
|
||||||
import { CustomerForm } from '@/components/customers/customer-form';
|
import { CustomerForm } from '@/components/customers/customer-form';
|
||||||
import { listClusters } from '@/lib/actions/clusters';
|
import { listClusters } from '@/lib/actions/clusters';
|
||||||
|
|
||||||
|
// Force dynamic rendering to ensure data is fetched at request time
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function NewCustomerPage() {
|
export default async function NewCustomerPage() {
|
||||||
const clusters = await listClusters();
|
const clusters = await listClusters();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ interface EditReleasePageProps {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function EditReleasePage({ params }: EditReleasePageProps) {
|
export default async function EditReleasePage({ params }: EditReleasePageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const release = await getReleaseById(parseInt(id));
|
const release = await getReleaseById(parseInt(id));
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { ArrowLeft, Edit, Play, Archive, Plus } from 'lucide-react';
|
import { ArrowLeft, Edit } 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';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { getReleaseById, activateRelease, archiveRelease } from '@/lib/actions/releases';
|
import { getReleaseById } 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';
|
import { ReleaseMatrixClient } from '@/components/releases/release-matrix-client';
|
||||||
|
import { ReleaseActions } from '@/components/releases/release-actions';
|
||||||
|
|
||||||
interface ReleaseDetailPageProps {
|
interface ReleaseDetailPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|
@ -37,6 +38,8 @@ function getStatusColor(status: string) {
|
||||||
return statusColors[status] || 'bg-slate-100 text-slate-800';
|
return statusColors[status] || 'bg-slate-100 text-slate-800';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function ReleaseDetailPage({ params }: ReleaseDetailPageProps) {
|
export default async function ReleaseDetailPage({ params }: ReleaseDetailPageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const releaseId = parseInt(id);
|
const releaseId = parseInt(id);
|
||||||
|
|
@ -55,19 +58,14 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
|
||||||
? await getStepStats(releaseId)
|
? await getStepStats(releaseId)
|
||||||
: { total: 0, done: 0, skipped: 0, pending: 0, reverted: 0, percentage: 0 };
|
: { total: 0, done: 0, skipped: 0, pending: 0, reverted: 0, percentage: 0 };
|
||||||
|
|
||||||
const customers = await listCustomers();
|
const allCustomers = await listCustomers();
|
||||||
|
|
||||||
async function handleActivate() {
|
// Get customer IDs already in this release
|
||||||
'use server';
|
const existingCustomerIds = Object.values(stepsByCluster).flatMap((clusterData: any) =>
|
||||||
await activateRelease(releaseId);
|
Object.values(clusterData.customers as Record<string, { customer: { id: number } }>).map(
|
||||||
redirect(`/releases/${releaseId}`);
|
(c: { customer: { id: number } }) => c.customer.id
|
||||||
}
|
)
|
||||||
|
);
|
||||||
async function handleArchive() {
|
|
||||||
'use server';
|
|
||||||
await archiveRelease(releaseId);
|
|
||||||
redirect(`/releases/${releaseId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -97,22 +95,13 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{release.status === 'draft' && (
|
<ReleaseActions
|
||||||
<form action={handleActivate}>
|
releaseId={releaseId}
|
||||||
<Button size="sm" type="submit">
|
releaseName={release.name}
|
||||||
<Play className="w-4 h-4 mr-2" />
|
releaseStatus={release.status}
|
||||||
Activate
|
allCustomers={allCustomers}
|
||||||
</Button>
|
existingCustomerIds={existingCustomerIds}
|
||||||
</form>
|
/>
|
||||||
)}
|
|
||||||
{release.status === 'active' && (
|
|
||||||
<form action={handleArchive}>
|
|
||||||
<Button variant="outline" size="sm" type="submit">
|
|
||||||
<Archive className="w-4 h-4 mr-2" />
|
|
||||||
Archive
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -130,7 +119,14 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-slate-500">Total Customers</label>
|
<label className="text-sm font-medium text-slate-500">Total Customers</label>
|
||||||
<p className="text-slate-900">{customers.length}</p>
|
<p className="text-slate-900">
|
||||||
|
{release.status === 'active' ? existingCustomerIds.length : allCustomers.length}
|
||||||
|
{release.status === 'active' && allCustomers.length > existingCustomerIds.length && (
|
||||||
|
<span className="text-slate-400 text-sm ml-1">
|
||||||
|
({allCustomers.length - existingCustomerIds.length} not in release)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{release.status === 'active' && (
|
{release.status === 'active' && (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -171,7 +167,6 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
|
||||||
<h3 className="font-medium mb-3">Deploy Steps ({release.templates.filter(t => t.category === 'deploy').length})</h3>
|
<h3 className="font-medium mb-3">Deploy Steps ({release.templates.filter(t => t.category === 'deploy').length})</h3>
|
||||||
<Link href={`/releases/${releaseId}/steps`}>
|
<Link href={`/releases/${releaseId}/steps`}>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Manage Deploy Steps
|
Manage Deploy Steps
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -180,7 +175,6 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
|
||||||
<h3 className="font-medium mb-3">Verify Steps ({release.templates.filter(t => t.category === 'verify').length})</h3>
|
<h3 className="font-medium mb-3">Verify Steps ({release.templates.filter(t => t.category === 'verify').length})</h3>
|
||||||
<Link href={`/releases/${releaseId}/steps`}>
|
<Link href={`/releases/${releaseId}/steps`}>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Manage Verify Steps
|
Manage Verify Steps
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Search, Users } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
interface CustomerWithCluster {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
cluster: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivateReleaseDialogProps {
|
||||||
|
releaseId: number;
|
||||||
|
releaseName: string;
|
||||||
|
customers: CustomerWithCluster[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onActivate: (customerIds: number[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivateReleaseDialog({
|
||||||
|
releaseName,
|
||||||
|
customers,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onActivate
|
||||||
|
}: ActivateReleaseDialogProps) {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize all customers as selected when dialog opens
|
||||||
|
useMemo(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectedIds(new Set(customers.map(c => c.id)));
|
||||||
|
}
|
||||||
|
}, [isOpen, customers]);
|
||||||
|
|
||||||
|
// Group customers by cluster
|
||||||
|
const groupedByCluster = useMemo(() => {
|
||||||
|
const filtered = customers.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.namespace.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.cluster.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return filtered.reduce((acc, customer) => {
|
||||||
|
const clusterName = customer.cluster.name;
|
||||||
|
if (!acc[clusterName]) {
|
||||||
|
acc[clusterName] = [];
|
||||||
|
}
|
||||||
|
acc[clusterName].push(customer);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, CustomerWithCluster[]>);
|
||||||
|
}, [customers, searchQuery]);
|
||||||
|
|
||||||
|
const toggleCustomer = (customerId: number) => {
|
||||||
|
const newSelected = new Set(selectedIds);
|
||||||
|
if (newSelected.has(customerId)) {
|
||||||
|
newSelected.delete(customerId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(customerId);
|
||||||
|
}
|
||||||
|
setSelectedIds(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCluster = (clusterCustomers: CustomerWithCluster[]) => {
|
||||||
|
const clusterIds = clusterCustomers.map(c => c.id);
|
||||||
|
const allSelected = clusterIds.every(id => selectedIds.has(id));
|
||||||
|
|
||||||
|
const newSelected = new Set(selectedIds);
|
||||||
|
if (allSelected) {
|
||||||
|
// Deselect all in cluster
|
||||||
|
clusterIds.forEach(id => newSelected.delete(id));
|
||||||
|
} else {
|
||||||
|
// Select all in cluster
|
||||||
|
clusterIds.forEach(id => newSelected.add(id));
|
||||||
|
}
|
||||||
|
setSelectedIds(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
setSelectedIds(new Set(customers.map(c => c.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAll = () => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = async () => {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await onActivate(Array.from(selectedIds));
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to activate release:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCustomers = customers.length;
|
||||||
|
const selectedCount = selectedIds.size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Activate Release: {releaseName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span>Selected {selectedCount} of {totalCustomers} customers</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={selectAll}>
|
||||||
|
Select All
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={deselectAll}>
|
||||||
|
Deselect All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search customers..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 border rounded-lg p-4 my-2">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(groupedByCluster).length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-center py-4">No customers found</p>
|
||||||
|
) : (
|
||||||
|
Object.entries(groupedByCluster).map(([clusterName, clusterCustomers]) => {
|
||||||
|
const allSelected = clusterCustomers.every(c => selectedIds.has(c.id));
|
||||||
|
const someSelected = clusterCustomers.some(c => selectedIds.has(c.id)) && !allSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={clusterName}>
|
||||||
|
<div className="flex items-center gap-2 mb-2 pb-2 border-b">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
data-state={someSelected ? 'indeterminate' : allSelected ? 'checked' : 'unchecked'}
|
||||||
|
onCheckedChange={() => toggleCluster(clusterCustomers)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-slate-700">
|
||||||
|
{clusterName} ({clusterCustomers.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-6 space-y-2">
|
||||||
|
{clusterCustomers.map((customer) => (
|
||||||
|
<div key={customer.id} className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(customer.id)}
|
||||||
|
onCheckedChange={() => toggleCustomer(customer.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="font-medium text-sm">{customer.name}</span>
|
||||||
|
<span className="text-slate-500 text-sm ml-2">
|
||||||
|
namespace: {customer.namespace}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleActivate}
|
||||||
|
disabled={selectedCount === 0 || isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Activating...' : `Activate (${selectedCount})`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Search, Users } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
|
interface CustomerWithCluster {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
cluster: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddCustomerDialogProps {
|
||||||
|
releaseId: number;
|
||||||
|
releaseName: string;
|
||||||
|
availableCustomers: CustomerWithCluster[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (customerIds: number[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddCustomerDialog({
|
||||||
|
releaseName,
|
||||||
|
availableCustomers,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onAdd
|
||||||
|
}: AddCustomerDialogProps) {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Clear selection when dialog opens
|
||||||
|
useMemo(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Group customers by cluster
|
||||||
|
const groupedByCluster = useMemo(() => {
|
||||||
|
const filtered = availableCustomers.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.namespace.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.cluster.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return filtered.reduce((acc, customer) => {
|
||||||
|
const clusterName = customer.cluster.name;
|
||||||
|
if (!acc[clusterName]) {
|
||||||
|
acc[clusterName] = [];
|
||||||
|
}
|
||||||
|
acc[clusterName].push(customer);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, CustomerWithCluster[]>);
|
||||||
|
}, [availableCustomers, searchQuery]);
|
||||||
|
|
||||||
|
const toggleCustomer = (customerId: number) => {
|
||||||
|
const newSelected = new Set(selectedIds);
|
||||||
|
if (newSelected.has(customerId)) {
|
||||||
|
newSelected.delete(customerId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(customerId);
|
||||||
|
}
|
||||||
|
setSelectedIds(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCluster = (clusterCustomers: CustomerWithCluster[]) => {
|
||||||
|
const clusterIds = clusterCustomers.map(c => c.id);
|
||||||
|
const allSelected = clusterIds.every(id => selectedIds.has(id));
|
||||||
|
|
||||||
|
const newSelected = new Set(selectedIds);
|
||||||
|
if (allSelected) {
|
||||||
|
clusterIds.forEach(id => newSelected.delete(id));
|
||||||
|
} else {
|
||||||
|
clusterIds.forEach(id => newSelected.add(id));
|
||||||
|
}
|
||||||
|
setSelectedIds(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await onAdd(Array.from(selectedIds));
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add customers:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCount = selectedIds.size;
|
||||||
|
|
||||||
|
if (availableCustomers.length === 0) {
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Customers to Release</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Users className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
||||||
|
<p className="text-slate-600">
|
||||||
|
All available customers are already in this release.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Customers to Release: {releaseName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span>Selected {selectedCount} customers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search customers..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 border rounded-lg p-4 my-2">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(groupedByCluster).length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-center py-4">No customers found</p>
|
||||||
|
) : (
|
||||||
|
Object.entries(groupedByCluster).map(([clusterName, clusterCustomers]) => {
|
||||||
|
const allSelected = clusterCustomers.every(c => selectedIds.has(c.id));
|
||||||
|
const someSelected = clusterCustomers.some(c => selectedIds.has(c.id)) && !allSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={clusterName}>
|
||||||
|
<div className="flex items-center gap-2 mb-2 pb-2 border-b">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
data-state={someSelected ? 'indeterminate' : allSelected ? 'checked' : 'unchecked'}
|
||||||
|
onCheckedChange={() => toggleCluster(clusterCustomers)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-slate-700">
|
||||||
|
{clusterName} ({clusterCustomers.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-6 space-y-2">
|
||||||
|
{clusterCustomers.map((customer) => (
|
||||||
|
<div key={customer.id} className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(customer.id)}
|
||||||
|
onCheckedChange={() => toggleCustomer(customer.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="font-medium text-sm">{customer.name}</span>
|
||||||
|
<span className="text-slate-500 text-sm ml-2">
|
||||||
|
namespace: {customer.namespace}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={selectedCount === 0 || isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Adding...' : `Add Customers (${selectedCount})`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Play, Archive } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ActivateReleaseDialog } from './activate-release-dialog';
|
||||||
|
import { AddCustomerDialog } from './add-customer-dialog';
|
||||||
|
import { activateRelease, addCustomersToRelease } from '@/lib/actions/releases';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface CustomerWithCluster {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
cluster: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseActionsProps {
|
||||||
|
releaseId: number;
|
||||||
|
releaseName: string;
|
||||||
|
releaseStatus: string;
|
||||||
|
allCustomers: CustomerWithCluster[];
|
||||||
|
// Customers already in the release (for active releases)
|
||||||
|
existingCustomerIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReleaseActions({
|
||||||
|
releaseId,
|
||||||
|
releaseName,
|
||||||
|
releaseStatus,
|
||||||
|
allCustomers,
|
||||||
|
existingCustomerIds = []
|
||||||
|
}: ReleaseActionsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isActivateDialogOpen, setIsActivateDialogOpen] = useState(false);
|
||||||
|
const [isAddCustomerDialogOpen, setIsAddCustomerDialogOpen] = useState(false);
|
||||||
|
const [isArchiving, setIsArchiving] = useState(false);
|
||||||
|
|
||||||
|
const handleActivate = async (customerIds: number[]) => {
|
||||||
|
await activateRelease(releaseId, customerIds);
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
if (!confirm('Are you sure you want to archive this release?')) return;
|
||||||
|
|
||||||
|
setIsArchiving(true);
|
||||||
|
try {
|
||||||
|
const { archiveRelease } = await import('@/lib/actions/releases');
|
||||||
|
await archiveRelease(releaseId);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to archive release:', error);
|
||||||
|
} finally {
|
||||||
|
setIsArchiving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCustomers = async (customerIds: number[]) => {
|
||||||
|
await addCustomersToRelease(releaseId, customerIds);
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate available customers (not already in release)
|
||||||
|
const availableCustomers = allCustomers.filter(
|
||||||
|
c => !existingCustomerIds.includes(c.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (releaseStatus === 'draft') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" onClick={() => setIsActivateDialogOpen(true)}>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ActivateReleaseDialog
|
||||||
|
releaseId={releaseId}
|
||||||
|
releaseName={releaseName}
|
||||||
|
customers={allCustomers}
|
||||||
|
isOpen={isActivateDialogOpen}
|
||||||
|
onClose={() => setIsActivateDialogOpen(false)}
|
||||||
|
onActivate={handleActivate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseStatus === 'active') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleArchive}
|
||||||
|
disabled={isArchiving}
|
||||||
|
>
|
||||||
|
<Archive className="w-4 h-4 mr-2" />
|
||||||
|
{isArchiving ? 'Archiving...' : 'Archive'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{availableCustomers.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsAddCustomerDialogOpen(true)}
|
||||||
|
>
|
||||||
|
+ Add Customer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddCustomerDialog
|
||||||
|
releaseId={releaseId}
|
||||||
|
releaseName={releaseName}
|
||||||
|
availableCustomers={availableCustomers}
|
||||||
|
isOpen={isAddCustomerDialogOpen}
|
||||||
|
onClose={() => setIsAddCustomerDialogOpen(false)}
|
||||||
|
onAdd={handleAddCustomers}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -71,13 +71,13 @@ export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* Line numbers column */}
|
{/* Line numbers column */}
|
||||||
<div className="flex flex-col text-slate-500 text-right pr-4 select-none min-w-[3rem]">
|
<div className="flex flex-col text-slate-500 text-right pr-4 select-none min-w-[3rem]">
|
||||||
{lines.map((_, i) => (
|
{lines.map((_: string, i: number) => (
|
||||||
<span key={i}>{i + 1}</span>
|
<span key={i}>{i + 1}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Code column */}
|
{/* Code column */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{highlightedLines.map((line, i) => (
|
{highlightedLines.map((line: string, i: number) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="text-slate-100 whitespace-pre"
|
className="text-slate-100 whitespace-pre"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
type ReleaseType,
|
type ReleaseType,
|
||||||
type ReleaseStatus,
|
type ReleaseStatus,
|
||||||
} from '@/lib/db/schema';
|
} from '@/lib/db/schema';
|
||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
export type ReleaseInput = {
|
export type ReleaseInput = {
|
||||||
|
|
@ -41,7 +41,7 @@ export async function updateRelease(id: number, data: Partial<ReleaseInput>) {
|
||||||
return release;
|
return release;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function activateRelease(id: number) {
|
export async function activateRelease(id: number, customerIds?: number[]) {
|
||||||
const release = await db.query.releases.findFirst({
|
const release = await db.query.releases.findFirst({
|
||||||
where: eq(releases.id, id),
|
where: eq(releases.id, id),
|
||||||
with: { templates: true },
|
with: { templates: true },
|
||||||
|
|
@ -50,12 +50,20 @@ export async function activateRelease(id: number) {
|
||||||
if (!release) throw new Error('Release not found');
|
if (!release) throw new Error('Release not found');
|
||||||
if (release.status !== 'draft') throw new Error('Release is not in draft status');
|
if (release.status !== 'draft') throw new Error('Release is not in draft status');
|
||||||
|
|
||||||
const activeCustomers = await db.query.customers.findMany({
|
// If customerIds not provided, use all active customers (backward compatible)
|
||||||
where: eq(customers.isActive, true),
|
const targetCustomers = customerIds
|
||||||
});
|
? await db.query.customers.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(customers.isActive, true),
|
||||||
|
inArray(customers.id, customerIds)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: await db.query.customers.findMany({
|
||||||
|
where: eq(customers.isActive, true),
|
||||||
|
});
|
||||||
|
|
||||||
// Create customer steps from templates
|
// Create customer steps from templates
|
||||||
const customerStepsToInsert = activeCustomers.flatMap(customer =>
|
const customerStepsToInsert = targetCustomers.flatMap(customer =>
|
||||||
release.templates.map(template => ({
|
release.templates.map(template => ({
|
||||||
releaseId: id,
|
releaseId: id,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
|
|
@ -83,6 +91,61 @@ export async function activateRelease(id: number) {
|
||||||
revalidatePath(`/releases/${id}`);
|
revalidatePath(`/releases/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addCustomersToRelease(releaseId: number, customerIds: number[]) {
|
||||||
|
const release = await db.query.releases.findFirst({
|
||||||
|
where: eq(releases.id, releaseId),
|
||||||
|
with: { templates: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!release) throw new Error('Release not found');
|
||||||
|
if (release.status !== 'active') throw new Error('Release must be active to add customers');
|
||||||
|
|
||||||
|
// Get existing customer IDs in this release
|
||||||
|
const existingSteps = await db.query.customerSteps.findMany({
|
||||||
|
where: eq(customerSteps.releaseId, releaseId),
|
||||||
|
columns: { customerId: true },
|
||||||
|
});
|
||||||
|
const existingCustomerIds = new Set(existingSteps.map((s: { customerId: number }) => s.customerId));
|
||||||
|
|
||||||
|
// Filter out customers already in the release
|
||||||
|
const newCustomerIds = customerIds.filter(id => !existingCustomerIds.has(id));
|
||||||
|
|
||||||
|
if (newCustomerIds.length === 0) {
|
||||||
|
throw new Error('All selected customers are already in this release');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the new customers
|
||||||
|
const newCustomers = await db.query.customers.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(customers.isActive, true),
|
||||||
|
inArray(customers.id, newCustomerIds)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create customer steps from current templates
|
||||||
|
const customerStepsToInsert = newCustomers.flatMap(customer =>
|
||||||
|
release.templates.map(template => ({
|
||||||
|
releaseId: releaseId,
|
||||||
|
customerId: customer.id,
|
||||||
|
templateId: template.id,
|
||||||
|
name: template.name,
|
||||||
|
category: template.category,
|
||||||
|
type: template.type,
|
||||||
|
content: template.content,
|
||||||
|
orderIndex: template.orderIndex,
|
||||||
|
status: 'pending' as const,
|
||||||
|
isCustom: false,
|
||||||
|
isOverridden: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (customerStepsToInsert.length > 0) {
|
||||||
|
await db.insert(customerSteps).values(customerStepsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/releases/${releaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function archiveRelease(id: number) {
|
export async function archiveRelease(id: number) {
|
||||||
await db.update(releases)
|
await db.update(releases)
|
||||||
.set({ status: 'archived', updatedAt: new Date() })
|
.set({ status: 'archived', updatedAt: new Date() })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue