508 lines
22 KiB
TypeScript
508 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import { useEntities, EntityFieldType } from '../../contexts/EntityContext'
|
|
import { Save, ArrowLeft, Plus, Trash2, Database, HelpCircle } from 'lucide-react'
|
|
import { CreateUpdateCustomEntityFieldDto, CustomEntityField } from '@/proxy/developerKit/models'
|
|
import { ROUTES_ENUM } from '@/routes/route.constant'
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
|
|
const EntityEditor: React.FC = () => {
|
|
const { id } = useParams()
|
|
const navigate = useNavigate()
|
|
const { translate } = useLocalization()
|
|
|
|
const { getEntity, addEntity, updateEntity } = useEntities()
|
|
|
|
const [name, setName] = useState('')
|
|
const [displayName, setDisplayName] = useState('')
|
|
const [tableName, setTableName] = useState('')
|
|
const [description, setDescription] = useState('')
|
|
const [fields, setFields] = useState<CustomEntityField[]>([])
|
|
const [isActive, setIsActive] = useState(true)
|
|
const [hasAuditFields, setHasAuditFields] = useState(true)
|
|
const [hasSoftDelete, setHasSoftDelete] = useState(true)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
|
|
const isEditing = !!id
|
|
|
|
// Check if migration is applied to disable certain fields
|
|
const isMigrationApplied = Boolean(
|
|
isEditing && id && getEntity(id)?.migrationStatus === 'applied',
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (isEditing && id) {
|
|
const entity = getEntity(id)
|
|
if (entity) {
|
|
setName(entity.name)
|
|
setDisplayName(entity.displayName)
|
|
setTableName(entity.tableName)
|
|
setDescription(entity.description || '')
|
|
setFields(entity.fields)
|
|
setIsActive(entity.isActive)
|
|
setHasAuditFields(entity.hasAuditFields)
|
|
setHasSoftDelete(entity.hasSoftDelete)
|
|
}
|
|
} else {
|
|
// Initialize with default field
|
|
setFields([
|
|
{
|
|
id: crypto.randomUUID(),
|
|
entityId: id || '',
|
|
name: 'Name',
|
|
type: 'string',
|
|
isRequired: true,
|
|
maxLength: 100,
|
|
description: 'Entity name',
|
|
},
|
|
])
|
|
}
|
|
}, [id, isEditing, getEntity])
|
|
|
|
const addField = () => {
|
|
const newField: CustomEntityField = {
|
|
id: crypto.randomUUID(),
|
|
entityId: id || '',
|
|
name: '',
|
|
type: 'string',
|
|
isRequired: false,
|
|
description: '',
|
|
}
|
|
setFields((prev) => [...prev, newField])
|
|
}
|
|
|
|
const updateField = (fieldKey: string, updates: Partial<CustomEntityField>) => {
|
|
setFields((prev) =>
|
|
prev.map((field) => (field.id === fieldKey ? { ...field, ...updates } : field)),
|
|
)
|
|
}
|
|
|
|
const removeField = (fieldKey: string) => {
|
|
setFields((prev) => prev.filter((field) => field.id !== fieldKey))
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!name.trim()) {
|
|
alert('Please enter an entity name')
|
|
return
|
|
}
|
|
|
|
if (!displayName.trim()) {
|
|
alert('Please enter a display name')
|
|
return
|
|
}
|
|
|
|
if (!tableName.trim()) {
|
|
alert('Please enter a table name')
|
|
return
|
|
}
|
|
|
|
if (fields.length === 0) {
|
|
alert('Please add at least one field')
|
|
return
|
|
}
|
|
|
|
const invalidFields = fields.filter((f) => !f.name.trim())
|
|
if (invalidFields.length > 0) {
|
|
alert('Please fill in all field names')
|
|
return
|
|
}
|
|
|
|
setIsSaving(true)
|
|
|
|
try {
|
|
const sanitizedFields = fields.map((f) => {
|
|
const sanitized: CreateUpdateCustomEntityFieldDto = {
|
|
...(f.id && isEditing ? { id: f.id } : {}), // sadece güncelleme modunda varsa gönder
|
|
name: f.name.trim(),
|
|
type: f.type,
|
|
isRequired: f.isRequired,
|
|
maxLength: f.maxLength,
|
|
isUnique: f.isUnique || false,
|
|
defaultValue: f.defaultValue,
|
|
description: f.description,
|
|
}
|
|
|
|
return sanitized
|
|
})
|
|
|
|
const entityData = {
|
|
name: name.trim(),
|
|
displayName: displayName.trim(),
|
|
tableName: tableName.trim(),
|
|
description: description.trim(),
|
|
fields: sanitizedFields,
|
|
isActive,
|
|
hasAuditFields,
|
|
hasSoftDelete,
|
|
}
|
|
|
|
if (isEditing && id) {
|
|
updateEntity(id, entityData)
|
|
} else {
|
|
addEntity(entityData)
|
|
}
|
|
|
|
navigate(ROUTES_ENUM.protected.saas.developerKitEntities)
|
|
} catch (error) {
|
|
console.error('Error saving entity:', error)
|
|
alert('Failed to save entity. Please try again.')
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const fieldTypes = [
|
|
{ value: 'string', label: 'String (Text)' },
|
|
{ value: 'number', label: 'Number (Integer)' },
|
|
{ value: 'decimal', label: 'Decimal' },
|
|
{ value: 'boolean', label: 'Boolean (True/False)' },
|
|
{ value: 'date', label: 'Date' },
|
|
{ value: 'guid', label: 'GUID (Unique ID)' },
|
|
]
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50">
|
|
<div className="w-full">
|
|
{/* Header */}
|
|
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6 shadow-sm">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => navigate(ROUTES_ENUM.protected.saas.developerKitEntities)}
|
|
className="flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
{translate('::App.DeveloperKit.EntityEditor.Back')}
|
|
</button>
|
|
<div className="h-6 w-px bg-slate-300" />
|
|
<div className="flex items-center gap-2">
|
|
<Database className="w-6 h-6 text-blue-600" />
|
|
<h1 className="text-xl font-semibold text-slate-900">
|
|
{isEditing
|
|
? translate('::App.DeveloperKit.EntityEditor.Title.Edit')
|
|
: translate('::App.DeveloperKit.EntityEditor.Title.Create')}
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Help Tooltip */}
|
|
<div className="relative group">
|
|
<button className="p-2 text-slate-400 hover:text-slate-600 transition-colors">
|
|
<HelpCircle className="w-4 h-4" />
|
|
</button>
|
|
<div className="absolute right-0 top-full mt-2 w-96 bg-slate-900 text-white text-sm rounded-lg p-4 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-xl">
|
|
<div className="absolute -top-1 right-4 w-2 h-2 bg-slate-900 rotate-45"></div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Database className="w-4 h-4 text-blue-400" />
|
|
<h4 className="font-semibold text-blue-200">{translate('::App.DeveloperKit.EntityEditor.Tooltip.Title')}</h4>
|
|
</div>
|
|
<p className="text-slate-300 leading-relaxed">
|
|
{translate('::App.DeveloperKit.EntityEditor.Tooltip.Content')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={isSaving || !name.trim() || !displayName.trim() || !tableName.trim()}
|
|
className="flex items-center gap-2 bg-emerald-600 text-white px-4 py-2 rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{isSaving ? translate('::App.DeveloperKit.EntityEditor.Saving') : translate('::App.DeveloperKit.EntityEditor.Save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Entity Basic Info */}
|
|
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6 shadow-sm">
|
|
<h2 className="text-lg font-semibold text-slate-900 mb-4">{translate('::App.DeveloperKit.EntityEditor.BasicInfo')}</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">{translate('::App.DeveloperKit.EntityEditor.EntityName')}</label>
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onBlur={() => {
|
|
if (!tableName) {
|
|
setTableName(name + 's')
|
|
}
|
|
if (!displayName) {
|
|
setDisplayName(name)
|
|
}
|
|
}}
|
|
disabled={isMigrationApplied}
|
|
className={`w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent ${
|
|
isMigrationApplied ? 'bg-slate-100 text-slate-500 cursor-not-allowed' : ''
|
|
}`}
|
|
placeholder="e.g., Product, User, Order"
|
|
/>
|
|
{isMigrationApplied && (
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
{translate('::App.DeveloperKit.EntityEditor.CannotChange')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
{translate('::App.DeveloperKit.EntityEditor.DisplayName')} *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
placeholder="e.g., Product, User, Order"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">{translate('::App.DeveloperKit.EntityEditor.TableName')} *</label>
|
|
<input
|
|
type="text"
|
|
value={tableName}
|
|
onChange={(e) => setTableName(e.target.value)}
|
|
disabled={isMigrationApplied}
|
|
className={`w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent ${
|
|
isMigrationApplied ? 'bg-slate-100 text-slate-500 cursor-not-allowed' : ''
|
|
}`}
|
|
placeholder="e.g., Products, Users, Orders"
|
|
/>
|
|
{isMigrationApplied && (
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Cannot be changed after migration is applied
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">{translate('::App.DeveloperKit.EntityEditor.Description')}</label>
|
|
<input
|
|
type="text"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
placeholder="Brief description of this entity"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{isEditing && (
|
|
<div className="mb-4 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
|
<h3 className="text-sm font-medium text-slate-700 mb-3">{translate('::App.DeveloperKit.EntityEditor.Status')}</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-slate-600">{translate('::App.DeveloperKit.EntityEditor.Status.Migration')}</span>
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded-full ${
|
|
getEntity(id!)?.migrationStatus === 'applied'
|
|
? 'bg-green-100 text-green-700'
|
|
: getEntity(id!)?.migrationStatus === 'pending'
|
|
? 'bg-yellow-100 text-yellow-700'
|
|
: 'bg-red-100 text-red-700'
|
|
}`}
|
|
>
|
|
{getEntity(id!)?.migrationStatus || 'pending'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-slate-600">{translate('::App.DeveloperKit.EntityEditor.Status.Endpoint')}</span>
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded-full ${
|
|
getEntity(id!)?.endpointStatus === 'applied'
|
|
? 'bg-blue-100 text-blue-700'
|
|
: getEntity(id!)?.endpointStatus === 'pending'
|
|
? 'bg-orange-100 text-orange-700'
|
|
: 'bg-red-100 text-red-700'
|
|
}`}
|
|
>
|
|
{getEntity(id!)?.endpointStatus || 'pending'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-6">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={isActive}
|
|
onChange={(e) => setIsActive(e.target.checked)}
|
|
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
|
/>
|
|
<span className="text-sm font-medium text-slate-700">{translate('::App.DeveloperKit.EntityEditor.Active')}</span>
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={hasAuditFields}
|
|
onChange={(e) => setHasAuditFields(e.target.checked)}
|
|
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
|
/>
|
|
<span className="text-sm font-medium text-slate-700">
|
|
{translate('::App.DeveloperKit.EntityEditor.Audit')}
|
|
<span className="text-slate-500 ml-1">
|
|
(creationTime, lastModificationTime, etc.)
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={hasSoftDelete}
|
|
onChange={(e) => setHasSoftDelete(e.target.checked)}
|
|
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
|
/>
|
|
<span className="text-sm font-medium text-slate-700">
|
|
{translate('::App.DeveloperKit.EntityEditor.SoftDelete')}
|
|
<span className="text-slate-500 ml-1">(IsDeleted field)</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Entity Fields */}
|
|
<div className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold text-slate-900">{translate('::App.DeveloperKit.EntityEditor.Fields')}</h2>
|
|
<button
|
|
onClick={addField}
|
|
className="flex items-center gap-2 bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{translate('::App.DeveloperKit.EntityEditor.AddField')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{fields.map((field, index) => (
|
|
<div
|
|
key={field.id || `new-${index}`}
|
|
className="border border-slate-200 rounded-lg p-4 bg-slate-50"
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
{translate('::App.DeveloperKit.EntityEditor.FieldName')} *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={field.name}
|
|
onChange={(e) => updateField(field.id, { name: e.target.value })}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
|
placeholder="e.g., Name, Price, IsActive"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">{translate('::App.DeveloperKit.EntityEditor.Type')} *</label>
|
|
<select
|
|
value={field.type}
|
|
onChange={(e) =>
|
|
updateField(field.id, {
|
|
type: e.target.value as EntityFieldType,
|
|
})
|
|
}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
|
>
|
|
{fieldTypes.map((type) => (
|
|
<option key={type.value} value={type.value}>
|
|
{type.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{field.type === 'string' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
{translate('::App.DeveloperKit.EntityEditor.MaxLength')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={field.maxLength || ''}
|
|
onChange={(e) =>
|
|
updateField(field.id, {
|
|
maxLength: e.target.value ? parseInt(e.target.value) : undefined,
|
|
})
|
|
}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
|
placeholder="e.g., 100"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
{translate('::App.DeveloperKit.EntityEditor.DefaultValue')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={field.defaultValue || ''}
|
|
onChange={(e) => updateField(field.id, { defaultValue: e.target.value })}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
|
placeholder="Optional default value"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
{translate('::App.DeveloperKit.EntityEditor.FieldDescription')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={field.description || ''}
|
|
onChange={(e) => updateField(field.id, { description: e.target.value })}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
|
placeholder="Field description"
|
|
/>
|
|
</div>
|
|
<div className="flex items-end gap-4">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.isRequired}
|
|
onChange={(e) =>
|
|
updateField(field.id, {
|
|
isRequired: e.target.checked,
|
|
})
|
|
}
|
|
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm font-medium text-slate-700">{translate('::App.DeveloperKit.EntityEditor.Required')}</span>
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.isUnique || false}
|
|
onChange={(e) => updateField(field.id, { isUnique: e.target.checked })}
|
|
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm font-medium text-slate-700">{translate('::App.DeveloperKit.EntityEditor.Unique')}</span>
|
|
</label>
|
|
<button
|
|
onClick={() => removeField(field.id)}
|
|
className="p-2 text-slate-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
|
title={translate('::App.DeveloperKit.EntityEditor.RemoveField')}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{fields.length === 0 && (
|
|
<div className="text-center py-8 text-slate-500">
|
|
<Database className="w-12 h-12 mx-auto mb-2 text-slate-300" />
|
|
<p>{translate('::App.DeveloperKit.EntityEditor.NoFields')}</p>
|
|
<p className="text-sm">{translate('::App.DeveloperKit.EntityEditor.NoFieldsDescription')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default EntityEditor
|