clone-helper

star 0

Clone and duplicate items functionality for HR-IMS

Arnutt-N By Arnutt-N schedule Updated 2/27/2026

name: clone-helper description: Clone and duplicate items functionality for HR-IMS version: 1.0.0 author: Claude Code triggers: keywords: ["clone", "duplicate", "copy", "replicate", "make copy"] file_patterns: ["clone", "duplicate", "copy"] context: cloning items, duplicating records, making copies mcp_servers: - sequential personas: - frontend - backend

Clone and Duplicate Helper

Core Role

Implement clone and duplicate functionality for HR-IMS:

  • Clone inventory items
  • Duplicate requests
  • Copy templates
  • Clone with modifications

Clone Service

// lib/clone/service.ts
import prisma from '@/lib/prisma'
import { AuditAction, createAuditLog } from '@/lib/audit/logger'

export interface CloneOptions {
  userId: number
  excludeFields?: string[]
  modifyFields?: Record<string, any>
  includeRelated?: boolean
  prefix?: string
}

// Clone inventory item
export async function cloneInventoryItem(
  sourceId: number,
  options: CloneOptions
): Promise<{ id: number; name: string }> {
  const source = await prisma.inventoryItem.findUnique({
    where: { id: sourceId },
    include: {
      category: { select: { id: true, name: true } },
      warehouse: { select: { id: true, name: true } }
    }
  })

  if (!source) {
    throw new Error('Item not found')
  }

  // Fields to exclude by default
  const excludeFields = new Set([
    'id',
    'createdAt',
    'updatedAt',
    'qrCode',
    'barcode',
    ...options.excludeFields || []
  ])

  // Build new item data
  const newItemData: Record<string, any> = {}

  Object.entries(source).forEach(([key, value]) => {
    if (!excludeFields.has(key)) {
      newItemData[key] = value
    }
  })

  // Apply modifications
  if (options.modifyFields) {
    Object.assign(newItemData, options.modifyFields)
  }

  // Add prefix to name
  if (options.prefix !== undefined) {
    newItemData.name = options.prefix + source.name
  } else {
    newItemData.name = `Copy of ${source.name}`
  }

  // Generate new serial number
  if (!options.modifyFields?.serialNumber) {
    newItemData.serialNumber = await generateUniqueSerialNumber(source.serialNumber)
  }

  // Create new item
  const newItem = await prisma.inventoryItem.create({
    data: newItemData
  })

  // Create audit log
  await createAuditLog({
    action: AuditAction.CREATE,
    tableName: 'InventoryItem',
    recordId: newItem.id,
    userId: options.userId,
    oldData: null,
    newData: {
      ...newItem,
      clonedFrom: sourceId
    }
  })

  // Clone related stock levels if requested
  if (options.includeRelated) {
    const stockLevels = await prisma.stockLevel.findMany({
      where: { itemId: sourceId }
    })

    for (const stock of stockLevels) {
      await prisma.stockLevel.create({
        data: {
          itemId: newItem.id,
          warehouseId: stock.warehouseId,
          quantity: 0, // Start with 0 for cloned items
          minQuantity: stock.minQuantity,
          maxQuantity: stock.maxQuantity
        }
      })
    }
  }

  return { id: newItem.id, name: newItem.name }
}

// Clone request with items
export async function cloneRequest(
  sourceId: number,
  options: CloneOptions
): Promise<{ id: number; requestCode: string }> {
  const source = await prisma.request.findUnique({
    where: { id: sourceId },
    include: {
      items: {
        include: {
          item: { select: { id: true, name: true } }
        }
      }
    }
  })

  if (!source) {
    throw new Error('Request not found')
  }

  // Generate new request code
  const newRequestCode = await generateUniqueRequestCode()

  // Create new request
  const newRequest = await prisma.request.create({
    data: {
      requestCode: newRequestCode,
      userId: options.userId,
      type: source.type,
      status: 'PENDING', // Always start as pending
      notes: source.notes,
      priority: source.priority,
      dueDate: source.dueDate,
      warehouseId: source.warehouseId,
      // Don't copy approval data
      approvedById: null,
      approvedAt: null,
      rejectedById: null,
      rejectedAt: null,
      rejectionReason: null
    }
  })

  // Clone request items
  for (const item of source.items) {
    await prisma.requestItem.create({
      data: {
        requestId: newRequest.id,
        itemId: item.itemId,
        quantity: item.quantity,
        notes: item.notes
      }
    })
  }

  // Create audit log
  await createAuditLog({
    action: AuditAction.CREATE,
    tableName: 'Request',
    recordId: newRequest.id,
    userId: options.userId,
    oldData: null,
    newData: {
      ...newRequest,
      clonedFrom: sourceId
    }
  })

  return { id: newRequest.id, requestCode: newRequestCode }
}

// Clone user (for creating similar users)
export async function cloneUser(
  sourceId: number,
  options: CloneOptions & { newEmail: string; newName: string }
): Promise<{ id: number; email: string }> {
  const source = await prisma.user.findUnique({
    where: { id: sourceId },
    include: {
      userRoles: { include: { role: true } },
      department: { select: { id: true } }
    }
  })

  if (!source) {
    throw new Error('User not found')
  }

  // Check email uniqueness
  const existingUser = await prisma.user.findUnique({
    where: { email: options.newEmail }
  })

  if (existingUser) {
    throw new Error('Email already exists')
  }

  // Create new user
  const newUser = await prisma.user.create({
    data: {
      email: options.newEmail,
      name: options.newName,
      password: source.password, // Will need to be changed on first login
      status: 'ACTIVE',
      departmentId: source.departmentId,
      avatar: source.avatar,
      phone: options.modifyFields?.phone || null,
      requirePasswordChange: true // Force password change
    }
  })

  // Clone roles
  for (const userRole of source.userRoles) {
    await prisma.userRole.create({
      data: {
        userId: newUser.id,
        roleId: userRole.roleId
      }
    })
  }

  // Create audit log
  await createAuditLog({
    action: AuditAction.CREATE,
    tableName: 'User',
    recordId: newUser.id,
    userId: options.userId,
    oldData: null,
    newData: {
      id: newUser.id,
      email: newUser.email,
      name: newUser.name,
      clonedFrom: sourceId
    }
  })

  return { id: newUser.id, email: newUser.email }
}

// Helper: Generate unique serial number
async function generateUniqueSerialNumber(original: string | null): Promise<string> {
  const base = original || 'ITEM'
  const timestamp = Date.now().toString(36).toUpperCase()

  let serialNumber = `${base}-${timestamp}`
  let counter = 1

  while (await prisma.inventoryItem.findFirst({ where: { serialNumber } })) {
    serialNumber = `${base}-${timestamp}-${counter}`
    counter++
  }

  return serialNumber
}

// Helper: Generate unique request code
async function generateUniqueRequestCode(): Promise<string> {
  const date = new Date()
  const year = date.getFullYear().toString().slice(-2)
  const month = (date.getMonth() + 1).toString().padStart(2, '0')
  const prefix = `REQ${year}${month}`

  const lastRequest = await prisma.request.findFirst({
    where: { requestCode: { startsWith: prefix } },
    orderBy: { requestCode: 'desc' }
  })

  let nextNum = 1
  if (lastRequest) {
    const lastNum = parseInt(lastRequest.requestCode.slice(-4))
    nextNum = lastNum + 1
  }

  return `${prefix}${nextNum.toString().padStart(4, '0')}`
}

// Batch clone items
export async function batchCloneItems(
  itemIds: number[],
  options: CloneOptions
): Promise<Array<{ sourceId: number; newItemId: number; name: string }>> {
  const results = []

  for (const sourceId of itemIds) {
    try {
      const newItem = await cloneInventoryItem(sourceId, options)
      results.push({
        sourceId,
        newItemId: newItem.id,
        name: newItem.name
      })
    } catch (error) {
      results.push({
        sourceId,
        newItemId: -1,
        name: '',
        error: error instanceof Error ? error.message : 'Unknown error'
      })
    }
  }

  return results
}

Clone Button Component

// components/clone/clone-button.tsx
'use client'

import { useState } from 'react'
import { useI18n } from '@/hooks/use-i18n'
import { Button } from '@/components/ui/button'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Copy, Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'

interface CloneButtonProps {
  type: 'item' | 'request' | 'user'
  sourceId: number
  sourceName: string
  onClone: (options: CloneDialogOptions) => Promise<{ id: number }>
  onSuccess?: (newId: number) => void
  variant?: 'default' | 'outline' | 'ghost'
  size?: 'default' | 'sm' | 'lg' | 'icon'
}

interface CloneDialogOptions {
  prefix?: string
  includeRelated?: boolean
  modifyFields?: Record<string, any>
}

export function CloneButton({
  type,
  sourceId,
  sourceName,
  onClone,
  onSuccess,
  variant = 'outline',
  size = 'sm'
}: CloneButtonProps) {
  const { locale } = useI18n()
  const router = useRouter()
  const [open, setOpen] = useState(false)
  const [loading, setLoading] = useState(false)
  const [prefix, setPrefix] = useState('')
  const [includeRelated, setIncludeRelated] = useState(false)

  const typeLabels = {
    item: { en: 'Item', th: 'พัสดุ' },
    request: { en: 'Request', th: 'คำขอ' },
    user: { en: 'User', th: 'ผู้ใช้' }
  }

  const typeLabel = locale === 'th' ? typeLabels[type].th : typeLabels[type].en

  const handleClone = async () => {
    setLoading(true)
    try {
      const result = await onClone({
        prefix: prefix || undefined,
        includeRelated
      })

      setOpen(false)

      if (onSuccess) {
        onSuccess(result.id)
      } else {
        // Navigate to new item
        router.push(`/${type === 'item' ? 'inventory' : type === 'request' ? 'requests' : 'users'}/${result.id}`)
      }
    } catch (error) {
      console.error('Clone failed:', error)
    } finally {
      setLoading(false)
    }
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant={variant} size={size}>
          <Copy className="h-4 w-4 mr-2" />
          {locale === 'th' ? 'คัดลอก' : 'Clone'}
        </Button>
      </DialogTrigger>

      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {locale === 'th' ? `คัดลอก${typeLabel}` : `Clone ${typeLabel}`}
          </DialogTitle>
          <DialogDescription>
            {locale === 'th'
              ? `สร้างสำเนาของ "${sourceName}"`
              : `Create a copy of "${sourceName}"`}
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-4 py-4">
          {type === 'item' && (
            <div className="space-y-2">
              <Label>
                {locale === 'th' ? 'คำนำหน้าชื่อ' : 'Name Prefix'}
              </Label>
              <Input
                value={prefix}
                onChange={(e) => setPrefix(e.target.value)}
                placeholder={locale === 'th' ? 'เว้นว่างเพื่อใช้ "Copy of"' : 'Leave empty for "Copy of"'}
              />
              <p className="text-xs text-muted-foreground">
                {locale === 'th'
                  ? 'ผลลัพธ์: [คำนำหน้า][ชื่อเดิม]'
                  : 'Result: [prefix][original name]'}
              </p>
            </div>
          )}

          {type === 'item' && (
            <div className="flex items-center space-x-2">
              <Checkbox
                id="include-related"
                checked={includeRelated}
                onCheckedChange={(checked) => setIncludeRelated(checked as boolean)}
              />
              <Label htmlFor="include-related" className="text-sm font-normal">
                {locale === 'th'
                  ? 'คัดลอกการตั้งค่าสต็อกด้วย'
                  : 'Include stock level settings'}
              </Label>
            </div>
          )}
        </div>

        <DialogFooter>
          <Button variant="outline" onClick={() => setOpen(false)}>
            {locale === 'th' ? 'ยกเลิก' : 'Cancel'}
          </Button>
          <Button onClick={handleClone} disabled={loading}>
            {loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
            {locale === 'th' ? 'คัดลอก' : 'Clone'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Batch Clone Dialog

// components/clone/batch-clone-dialog.tsx
'use client'

import { useState } from 'react'
import { useI18n } from '@/hooks/use-i18n'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { CheckCircle, XCircle, Loader2 } from 'lucide-react'

interface BatchCloneDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  items: Array<{ id: number; name: string }>
  onClone: (itemIds: number[]) => Promise<Array<{ sourceId: number; newItemId: number; name?: string; error?: string }>>
}

export function BatchCloneDialog({
  open,
  onOpenChange,
  items,
  onClone
}: BatchCloneDialogProps) {
  const { locale } = useI18n()
  const [loading, setLoading] = useState(false)
  const [results, setResults] = useState<Array<{
    sourceId: number
    sourceName: string
    success: boolean
    newItemId?: number
    newName?: string
    error?: string
  }>>([])

  const handleClone = async () => {
    setLoading(true)
    setResults([])

    try {
      const cloneResults = await onClone(items.map(i => i.id))

      setResults(
        cloneResults.map((r, index) => ({
          sourceId: r.sourceId,
          sourceName: items.find(i => i.id === r.sourceId)?.name || '',
          success: r.newItemId > 0,
          newItemId: r.newItemId,
          newName: r.name,
          error: r.error
        }))
      )
    } catch (error) {
      console.error('Batch clone failed:', error)
    } finally {
      setLoading(false)
    }
  }

  const successCount = results.filter(r => r.success).length
  const failCount = results.filter(r => !r.success).length

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-md">
        <DialogHeader>
          <DialogTitle>
            {locale === 'th' ? 'คัดลอกหลายรายการ' : 'Batch Clone'}
          </DialogTitle>
          <DialogDescription>
            {locale === 'th'
              ? `คัดลอก ${items.length} รายการที่เลือก`
              : `Clone ${items.length} selected items`}
          </DialogDescription>
        </DialogHeader>

        {results.length === 0 ? (
          <div className="py-4">
            <p className="text-sm text-muted-foreground mb-4">
              {locale === 'th'
                ? 'รายการที่จะคัดลอก:'
                : 'Items to clone:'}
            </p>
            <ScrollArea className="h-40">
              <ul className="space-y-1">
                {items.map((item) => (
                  <li key={item.id} className="text-sm">
                    • {item.name}
                  </li>
                ))}
              </ul>
            </ScrollArea>
          </div>
        ) : (
          <div className="py-4">
            <div className="flex gap-2 mb-4">
              {successCount > 0 && (
                <Badge variant="default" className="bg-green-500">
                  <CheckCircle className="h-3 w-3 mr-1" />
                  {successCount} {locale === 'th' ? 'สำเร็จ' : 'success'}
                </Badge>
              )}
              {failCount > 0 && (
                <Badge variant="destructive">
                  <XCircle className="h-3 w-3 mr-1" />
                  {failCount} {locale === 'th' ? 'ล้มเหลว' : 'failed'}
                </Badge>
              )}
            </div>

            <ScrollArea className="h-40">
              <ul className="space-y-2">
                {results.map((r) => (
                  <li
                    key={r.sourceId}
                    className="flex items-center gap-2 text-sm"
                  >
                    {r.success ? (
                      <CheckCircle className="h-4 w-4 text-green-500" />
                    ) : (
                      <XCircle className="h-4 w-4 text-destructive" />
                    )}
                    <span className="flex-1 truncate">{r.sourceName}</span>
                    {r.success && r.newName && (
                      <span className="text-xs text-muted-foreground">
                        → {r.newName}
                      </span>
                    )}
                  </li>
                ))}
              </ul>
            </ScrollArea>
          </div>
        )}

        <DialogFooter>
          <Button
            variant="outline"
            onClick={() => {
              setResults([])
              onOpenChange(false)
            }}
          >
            {results.length > 0
              ? locale === 'th' ? 'ปิด' : 'Close'
              : locale === 'th' ? 'ยกเลิก' : 'Cancel'}
          </Button>
          {results.length === 0 && (
            <Button onClick={handleClone} disabled={loading}>
              {loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
              {locale === 'th' ? 'คัดลอก' : 'Clone'}
            </Button>
          )}
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Usage Examples

// Example 1: Clone button on inventory item
import { CloneButton } from '@/components/clone/clone-button'
import { cloneInventoryItem } from '@/lib/clone/service'

function ItemDetailPage({ item, session }) {
  const handleClone = async (options) => {
    return cloneInventoryItem(item.id, {
      userId: parseInt(session.user.id),
      prefix: options.prefix,
      includeRelated: options.includeRelated
    })
  }

  return (
    <div>
      <h1>{item.name}</h1>

      <CloneButton
        type="item"
        sourceId={item.id}
        sourceName={item.name}
        onClone={handleClone}
      />
    </div>
  )
}

// Example 2: Clone request
function RequestDetailPage({ request, session }) {
  const handleClone = async (options) => {
    return cloneRequest(request.id, {
      userId: parseInt(session.user.id)
    })
  }

  return (
    <div>
      <CloneButton
        type="request"
        sourceId={request.id}
        sourceName={request.requestCode}
        onClone={handleClone}
      />
    </div>
  )
}

// Example 3: Batch clone in inventory table
import { BatchCloneDialog } from '@/components/clone/batch-clone-dialog'
import { batchCloneItems } from '@/lib/clone/service'

function InventoryTable({ session }) {
  const [selectedItems, setSelectedItems] = useState([])
  const [showCloneDialog, setShowCloneDialog] = useState(false)

  const handleBatchClone = async (itemIds: number[]) => {
    return batchCloneItems(itemIds, {
      userId: parseInt(session.user.id)
    })
  }

  return (
    <div>
      <Button
        onClick={() => setShowCloneDialog(true)}
        disabled={selectedItems.length === 0}
      >
        <Copy className="h-4 w-4 mr-2" />
        Clone Selected ({selectedItems.length})
      </Button>

      <BatchCloneDialog
        open={showCloneDialog}
        onOpenChange={setShowCloneDialog}
        items={selectedItems}
        onClone={handleBatchClone}
      />
    </div>
  )
}

// Example 4: Quick clone with default options
async function quickCloneItem(itemId: number, userId: number) {
  return cloneInventoryItem(itemId, {
    userId,
    prefix: '',
    includeRelated: false
  })
}

Version: 1.0.0 | For HR-IMS Project

Install via CLI
npx skills add https://github.com/Arnutt-N/hr-ims --skill clone-helper
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator