WizardManager için Import özelliği eklendi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-18 22:36:51 +03:00
parent 010594ac27
commit 05ccada4f0
32 changed files with 2065 additions and 190 deletions

4
.gitignore vendored
View file

@ -7,4 +7,6 @@ logs/
# Deploy sırasında git tag'lerinden üretilir (configs/change-logs/generate-change-logs.sh) # Deploy sırasında git tag'lerinden üretilir (configs/change-logs/generate-change-logs.sh)
# ve API konteynerine mount edilir. Takip edilirse deploy'daki `git pull` yerel # ve API konteynerine mount edilir. Takip edilirse deploy'daki `git pull` yerel
# değişiklikle çakışır ve dosya eskir. # değişiklikle çakışır ve dosya eskir.
configs/change-logs/change-logs.json configs/change-logs/change-logs.json
# Wizard import staging klasoru (gecici; oturum bitince silinir)
configs/seeds/.imports/

View file

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Volo.Abp.Content; using Volo.Abp.Content;
@ -13,5 +13,19 @@ public interface IListFormWizardAppService
/// <summary>Wizard dosyasini ve bagli seed dosyalarini zip olarak dondurur.</summary> /// <summary>Wizard dosyasini ve bagli seed dosyalarini zip olarak dondurur.</summary>
Task<IRemoteStreamContent> ExportFile(string fileName); Task<IRemoteStreamContent> ExportFile(string fileName);
}
/// <summary>
/// Export zip'ini staging klasorune acar ve her dosya icin hedefteki karsiligiyla
/// karsilastirma sonucunu dondurur. Bu adim hicbir hedef dosyaya dokunmaz.
/// </summary>
Task<WizardImportAnalysisDto> AnalyzeImport(IRemoteStreamContent file);
/// <summary>Analizde donen tek bir dosyayi hedefe yazar; onceki icerik rollback icin yedeklenir.</summary>
Task<WizardImportApplyResultDto> ApplyImport(WizardImportApplyInput input);
/// <summary>Import oturumunu basariyla kapatir ve staging/yedek klasorunu siler.</summary>
Task CompleteImport(string importId);
/// <summary>O ana kadar yazilan dosyalari geri alir ve staging klasorunu siler.</summary>
Task RollbackImport(string importId);
}

View file

@ -1,6 +1,7 @@
using Sozsoft.Platform.Enums; using Sozsoft.Platform.Enums;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data; using System.Data;
using System.Text.Json.Serialization;
namespace Sozsoft.Platform.ListForms; namespace Sozsoft.Platform.ListForms;
@ -13,6 +14,14 @@ public class ListFormWizardDto
/// </summary> /// </summary>
public WizardComponentKindEnum ComponentKind { get; set; } = WizardComponentKindEnum.List; public WizardComponentKindEnum ComponentKind { get; set; } = WizardComponentKindEnum.List;
/// <summary>
/// Duzenleme modunda, yerine gecilecek seed dosyasinin adi. Doluysa istek bir guncellemedir:
/// sunucu once bu dosyayi ve urettigi kayitlari siler, sonra yenisini uretir.
/// Yalnizca istek girdisidir; seed dosyasina yazilmaz.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string EditFileName { get; set; }
public string WizardName { get; set; } public string WizardName { get; set; }
public string ListFormCode { get; set; } public string ListFormCode { get; set; }
public string MenuCode { get; set; } public string MenuCode { get; set; }

View file

@ -0,0 +1,72 @@
using System.Collections.Generic;
using Sozsoft.Platform.Enums;
namespace Sozsoft.Platform.ListForms;
/// <summary>
/// Import edilen zip icindeki tek bir seed dosyasinin analiz sonucu.
/// Yol her zaman kapsam klasorune (host / tenants/{tenantId}) goreli tutulur.
/// </summary>
public class WizardImportEntryDto
{
/// <summary>Kapsam klasorune goreli yol, ornegin <c>custom/Orders.json</c>.</summary>
public string Path { get; set; }
/// <summary>Yolun ilk segmenti; listede gruplama icin kullanilir (wizard, crud, custom, sql, postgres).</summary>
public string Folder { get; set; }
public string FileName { get; set; }
public WizardImportEntryStatusEnum Status { get; set; }
public long IncomingSize { get; set; }
public long ExistingSize { get; set; }
/// <summary>Zip'ten gelen icerik; yalnizca Conflict durumunda doldurulur.</summary>
public string IncomingContent { get; set; }
/// <summary>Hedefteki mevcut icerik; yalnizca Conflict durumunda doldurulur.</summary>
public string ExistingContent { get; set; }
}
/// <summary>Zip analizinin tamami; uygulama adimi bu <see cref="ImportId"/> ile yurutulur.</summary>
public class WizardImportAnalysisDto
{
/// <summary>Staging klasorunu ve rollback kaydini tanimlayan oturum kimligi.</summary>
public string ImportId { get; set; }
/// <summary>Dosyalarin yazilacagi kapsam klasoru (<c>host</c> ya da <c>tenants/{tenantId}</c>).</summary>
public string ScopeFolder { get; set; }
public List<WizardImportEntryDto> Entries { get; set; } = [];
/// <summary>Guvenlik/format nedeniyle atlanan zip girdileri icin aciklamalar.</summary>
public List<string> Warnings { get; set; } = [];
}
/// <summary>Tek bir dosyanin hedefe yazilmasi istegi.</summary>
public class WizardImportApplyInput
{
public string ImportId { get; set; }
/// <summary>Analizde donen kapsam-goreli yol.</summary>
public string Path { get; set; }
/// <summary>
/// Kullanicinin diff ekraninda birlestirdigi icerik. Bos birakilirsa zip'ten gelen
/// icerik oldugu gibi yazilir.
/// </summary>
public string Content { get; set; }
}
/// <summary>Tek dosya yazma sonucu; ilerleme gostergesinde satir olarak gosterilir.</summary>
public class WizardImportApplyResultDto
{
public string Path { get; set; }
/// <summary>Dosya hedefte zaten varken uzerine yazildiysa true.</summary>
public bool Overwritten { get; set; }
public long Size { get; set; }
}

View file

@ -11,12 +11,20 @@ using Microsoft.Extensions.Logging;
using Sozsoft.Platform.Data.Seeds; using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.DeveloperKit; using Sozsoft.Platform.DeveloperKit;
using Sozsoft.Platform.Entities; using Sozsoft.Platform.Entities;
using static Sozsoft.Platform.PlatformConsts;
using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
namespace Platform.Api.Application; namespace Platform.Api.Application;
/// <summary>
/// Custom component yonetimi. Sinif seviyesindeki <see cref="AuthorizeAttribute"/> yalnizca
/// kimlik dogrulamasi ister; ekrani gorme ve CRUD haklari asagida ABP policy adlarina baglanir.
/// <c>App.DeveloperKit.Components</c> okuma, <c>.Create</c>/<c>.Update</c>/<c>.Delete</c> aksiyon
/// yetkileridir. <see cref="GetActiveComponentsAsync"/> calisma zamani okuma yoludur ve yalnizca
/// oturum acmis olmayi ister.
/// </summary>
[Authorize] [Authorize]
public class CustomComponentAppService : CrudAppService< public class CustomComponentAppService : CrudAppService<
CustomComponent, CustomComponent,
@ -47,6 +55,12 @@ public class CustomComponentAppService : CrudAppService<
{ {
_crudEndpointRepository = crudEndpointRepository; _crudEndpointRepository = crudEndpointRepository;
_configuration = configuration; _configuration = configuration;
GetPolicyName = AppCodes.DeveloperKits.Components.Component;
GetListPolicyName = AppCodes.DeveloperKits.Components.Component;
CreatePolicyName = AppCodes.DeveloperKits.Components.Create;
UpdatePolicyName = AppCodes.DeveloperKits.Components.Update;
DeletePolicyName = AppCodes.DeveloperKits.Components.Delete;
} }
public override async Task<CustomComponentDto> CreateAsync(CreateUpdateCustomComponentDto input) public override async Task<CustomComponentDto> CreateAsync(CreateUpdateCustomComponentDto input)

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -31,6 +32,10 @@ namespace Sozsoft.Platform.ListForms;
/// <summary> /// <summary>
/// Wizard, global menu/permission/language kayitlari ve seed dosyalari uretir. /// Wizard, global menu/permission/language kayitlari ve seed dosyalari uretir.
/// Bu nedenle sadece host tarafinda tanimli olan <c>App.Listforms.Wizard</c> yetkisi ile calisir. /// Bu nedenle sadece host tarafinda tanimli olan <c>App.Listforms.Wizard</c> yetkisi ile calisir.
/// <para>
/// Sinif seviyesindeki yetki ekrani gorme (okuma) hakkidir; aksiyonlar ayrica kendi alt
/// yetkilerini ister: <c>.Create</c>, <c>.Update</c>, <c>.Delete</c>, <c>.Export</c>, <c>.Import</c>.
/// </para>
/// </summary> /// </summary>
[Authorize(AppCodes.Listforms.Wizard)] [Authorize(AppCodes.Listforms.Wizard)]
public class ListFormWizardAppService( public class ListFormWizardAppService(
@ -76,6 +81,19 @@ public class ListFormWizardAppService(
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
// Duzenleme, eski dosyanin (ve urettigi kayitlarin) silinip yeniden uretilmesidir.
// Ayrimi istemci degil sunucu yapar; boylece yetki dogru politikaya baglanir ve
// silme ile yeniden uretme arasinda yarim kalmis bir durum olusmaz.
var editFileName = input.EditFileName?.Trim();
var isEdit = !string.IsNullOrWhiteSpace(editFileName);
await AuthorizationService.CheckAsync(isEdit
? AppCodes.Listforms.Wizards.Update
: AppCodes.Listforms.Wizards.Create);
if (isEdit)
await DeleteFileInternalAsync(editFileName!);
var wizardName = input.WizardName?.Trim(); var wizardName = input.WizardName?.Trim();
if (string.IsNullOrWhiteSpace(wizardName)) if (string.IsNullOrWhiteSpace(wizardName))
throw new UserFriendlyException("Wizard adı zorunludur."); throw new UserFriendlyException("Wizard adı zorunludur.");
@ -583,6 +601,9 @@ public class ListFormWizardAppService(
var outputPath = ResolveWizardSeedOutputPath(); var outputPath = ResolveWizardSeedOutputPath();
Directory.CreateDirectory(outputPath); Directory.CreateDirectory(outputPath);
// Duzenleme bilgisi yalnizca istege aittir; seed dosyasinda yeri yoktur.
input.EditFileName = null;
var seedData = new WizardSeedFileDto var seedData = new WizardSeedFileDto
{ {
Wizard = input, Wizard = input,
@ -694,7 +715,17 @@ public class ListFormWizardAppService(
} }
[UnitOfWork] [UnitOfWork]
public async Task DeleteFile(string fileName) [Authorize(AppCodes.Listforms.Wizards.Delete)]
public Task DeleteFile(string fileName)
{
return DeleteFileInternalAsync(fileName);
}
/// <summary>
/// Seed dosyasini ve wizard'in urettigi kayitlari siler. Yetki kontrolu cagiran uca aittir:
/// silme yolunda <c>Delete</c>, duzenleme yolunda <c>Update</c> politikasi kullanilir.
/// </summary>
private async Task DeleteFileInternalAsync(string fileName)
{ {
var filePath = ResolveWizardSeedFilePath(fileName); var filePath = ResolveWizardSeedFilePath(fileName);
@ -906,6 +937,7 @@ public class ListFormWizardAppService(
/// <c>{sql|postgres}/{object|execute}/{nesne}.sql</c> ve varsa <c>crud/{nesne}.json</c>. /// <c>{sql|postgres}/{object|execute}/{nesne}.sql</c> ve varsa <c>crud/{nesne}.json</c>.
/// </para> /// </para>
/// </summary> /// </summary>
[Authorize(AppCodes.Listforms.Wizards.Export)]
[HttpGet("api/app/list-form-wizard/export")] [HttpGet("api/app/list-form-wizard/export")]
public async Task<IRemoteStreamContent> ExportFile(string fileName) public async Task<IRemoteStreamContent> ExportFile(string fileName)
{ {
@ -951,6 +983,404 @@ public class ListFormWizardAppService(
return new RemoteStreamContent(memory, $"{safeName}.zip", "application/zip"); return new RemoteStreamContent(memory, $"{safeName}.zip", "application/zip");
} }
// ── Import ─────────────────────────────────────────────────────────────────────
// Import iki adimlidir: once zip staging klasorune acilir ve her dosya hedefteki
// karsiligiyla karsilastirilir (AnalyzeImport), kullanici catismalari cozdukten sonra
// dosyalar tek tek yazilir (ApplyImport). Yazilan her dosyanin onceki hali staging
// altindaki backup klasorune alindigi icin hata durumunda RollbackImport ile tum
// toplu islem geri alinabilir.
/// <summary>Staging oturumlarinin tutuldugu klasor; seed kokunun altinda nokta ile baslar, seeder taramalarina girmez.</summary>
private const string ImportSessionsFolder = ".imports";
private const string ImportStagedFolder = "staged";
private const string ImportBackupFolder = "backup";
private const string ImportAppliedLogFile = "applied.log";
private const long ImportMaxEntryBytes = 5 * 1024 * 1024;
private const long ImportMaxArchiveBytes = 50 * 1024 * 1024;
private const int ImportMaxEntryCount = 500;
/// <summary>Kapsam klasorunde import edilmesine izin verilen kok klasorler.</summary>
private static readonly string[] ImportAllowedRootFolders =
[
SeedPathResolver.WizardFolder,
SeedPathResolver.CrudFolder,
SeedPathResolver.CustomFolder,
SeedPathResolver.SqlFolder,
SeedPathResolver.PostgresFolder,
];
/// <summary>SQL saglayici klasorlerinin altinda izin verilen script klasorleri.</summary>
private static readonly string[] ImportAllowedScriptFolders =
[
SeedPathResolver.ObjectFolder,
SeedPathResolver.ExecuteFolder,
];
private static readonly UTF8Encoding ImportEncoding = new(encoderShouldEmitUTF8Identifier: false);
[Authorize(AppCodes.Listforms.Wizards.Import)]
[HttpPost("api/app/list-form-wizard/analyze-import")]
public async Task<WizardImportAnalysisDto> AnalyzeImport([FromForm] IRemoteStreamContent file)
{
if (file == null)
throw new UserFriendlyException("Dosya bulunamadı.");
CleanupStaleImportSessions();
using var buffer = new MemoryStream();
await using (var upload = file.GetStream())
await upload.CopyToAsync(buffer);
if (buffer.Length == 0)
throw new UserFriendlyException("Dosya boş.");
if (buffer.Length > ImportMaxArchiveBytes)
throw new UserFriendlyException("Zip dosyası çok büyük.");
buffer.Position = 0;
ZipArchive archive;
try
{
archive = new ZipArchive(buffer, ZipArchiveMode.Read);
}
catch (InvalidDataException)
{
throw new UserFriendlyException("Geçerli bir zip dosyası değil.");
}
var importId = GuidGenerator.Create().ToString("N");
var sessionPath = GetImportSessionPath(importId);
var stagedRoot = Path.Combine(sessionPath, ImportStagedFolder);
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id));
var result = new WizardImportAnalysisDto
{
ImportId = importId,
ScopeFolder = SeedPathResolver.GetScopeFolderName(CurrentTenant.Id),
};
using (archive)
{
Directory.CreateDirectory(stagedRoot);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var zipEntry in archive.Entries)
{
// Klasor girdilerinin adi bostur; icerik tasimazlar.
if (string.IsNullOrEmpty(zipEntry.Name))
continue;
var relativePath = NormalizeImportEntryPath(zipEntry.FullName);
if (relativePath == null)
{
result.Warnings.Add($"{zipEntry.FullName}: desteklenmeyen klasör/dosya, atlandı.");
continue;
}
if (zipEntry.Length > ImportMaxEntryBytes)
{
result.Warnings.Add($"{relativePath}: dosya çok büyük, atlandı.");
continue;
}
if (!seen.Add(relativePath))
{
result.Warnings.Add($"{relativePath}: zip içinde birden fazla kez var, ilki kullanıldı.");
continue;
}
if (seen.Count > ImportMaxEntryCount)
throw new UserFriendlyException("Zip içindeki dosya sayısı çok fazla.");
byte[] incoming;
await using (var source = zipEntry.Open())
using (var entryBuffer = new MemoryStream())
{
await source.CopyToAsync(entryBuffer);
incoming = entryBuffer.ToArray();
}
var stagedPath = Path.Combine(stagedRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!);
await File.WriteAllBytesAsync(stagedPath, incoming);
var targetPath = Path.Combine(scopeRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
var segments = relativePath.Split('/');
var entry = new WizardImportEntryDto
{
Path = relativePath,
Folder = segments[0],
FileName = segments[^1],
IncomingSize = incoming.LongLength,
};
if (!File.Exists(targetPath))
{
entry.Status = WizardImportEntryStatusEnum.New;
}
else
{
var existing = await File.ReadAllBytesAsync(targetPath);
entry.ExistingSize = existing.LongLength;
var incomingText = NormalizeImportText(incoming);
var existingText = NormalizeImportText(existing);
if (string.Equals(incomingText, existingText, StringComparison.Ordinal))
{
entry.Status = WizardImportEntryStatusEnum.Identical;
}
else
{
// Karsilastirma ekrani yalnizca farkli dosyalar icin acildigindan
// icerikler sadece bu durumda tasinir.
entry.Status = WizardImportEntryStatusEnum.Conflict;
entry.IncomingContent = incomingText;
entry.ExistingContent = existingText;
}
}
result.Entries.Add(entry);
}
}
if (result.Entries.Count == 0)
{
DeleteImportSession(sessionPath);
throw new UserFriendlyException("Zip içinde import edilebilecek seed dosyası bulunamadı.");
}
result.Entries = [.. result.Entries.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)];
return result;
}
[Authorize(AppCodes.Listforms.Wizards.Import)]
[HttpPost("api/app/list-form-wizard/apply-import")]
public async Task<WizardImportApplyResultDto> ApplyImport(WizardImportApplyInput input)
{
Check.NotNull(input, nameof(input));
var sessionPath = GetExistingImportSessionPath(input.ImportId);
var relativePath = NormalizeImportEntryPath(input.Path)
?? throw new UserFriendlyException("Geçersiz dosya yolu.");
var systemPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
var stagedPath = Path.Combine(sessionPath, ImportStagedFolder, systemPath);
if (!File.Exists(stagedPath))
throw new UserFriendlyException($"Import oturumunda dosya bulunamadı: {relativePath}");
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id));
var targetPath = Path.GetFullPath(Path.Combine(scopeRoot, systemPath));
if (!targetPath.StartsWith(scopeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new UserFriendlyException("Geçersiz dosya yolu.");
// Icerik gonderildiyse kullanici diff ekraninda birlestirmis demektir; gonderilmediyse
// zip'ten gelen dosya oldugu gibi yazilir.
var content = input.Content == null
? await File.ReadAllBytesAsync(stagedPath)
: ImportEncoding.GetBytes(input.Content);
var overwritten = File.Exists(targetPath);
if (overwritten)
{
var backupPath = Path.Combine(sessionPath, ImportBackupFolder, systemPath);
// Ayni dosya ikinci kez yazilirsa ilk yedek korunur; rollback en bastaki hale doner.
if (!File.Exists(backupPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
File.Copy(targetPath, backupPath, overwrite: true);
}
}
// Kayit yazma isleminden once atilir; yazma sirasinda surec olse bile rollback dosyayi bulur.
await File.AppendAllTextAsync(
Path.Combine(sessionPath, ImportAppliedLogFile),
$"{(overwritten ? "E" : "N")}|{relativePath}{Environment.NewLine}");
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
await File.WriteAllBytesAsync(targetPath, content);
logger.LogInformation("Wizard import applied: {RelativePath} (overwritten: {Overwritten})", relativePath, overwritten);
return new WizardImportApplyResultDto
{
Path = relativePath,
Overwritten = overwritten,
Size = content.LongLength,
};
}
[Authorize(AppCodes.Listforms.Wizards.Import)]
[HttpPost("api/app/list-form-wizard/complete-import")]
public Task CompleteImport(string importId)
{
DeleteImportSession(GetExistingImportSessionPath(importId));
return Task.CompletedTask;
}
[Authorize(AppCodes.Listforms.Wizards.Import)]
[HttpPost("api/app/list-form-wizard/rollback-import")]
public async Task RollbackImport(string importId)
{
var sessionPath = GetExistingImportSessionPath(importId);
var logPath = Path.Combine(sessionPath, ImportAppliedLogFile);
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id));
if (File.Exists(logPath))
{
var lines = await File.ReadAllLinesAsync(logPath);
// Yazma sirasinin tersine gidilir; ayni dosya birden fazla kez yazildiysa
// en eski yedek (ilk kayit) en sonda uygulanir.
foreach (var line in lines.Reverse())
{
var parts = line.Split('|', 2);
if (parts.Length != 2)
continue;
var systemPath = parts[1].Replace('/', Path.DirectorySeparatorChar);
var targetPath = Path.GetFullPath(Path.Combine(scopeRoot, systemPath));
if (!targetPath.StartsWith(scopeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
continue;
try
{
if (parts[0] == "E")
{
var backupPath = Path.Combine(sessionPath, ImportBackupFolder, systemPath);
if (File.Exists(backupPath))
File.Copy(backupPath, targetPath, overwrite: true);
}
else if (File.Exists(targetPath))
{
File.Delete(targetPath);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Wizard import rollback failed for {RelativePath}", parts[1]);
}
}
}
DeleteImportSession(sessionPath);
}
/// <summary>
/// Zip girdisini kapsam klasorune goreli yola cevirir. Zip, kapsam klasorunun icinden
/// (<c>wizard/x.json</c>) ya da depo kokunden (<c>configs/seeds/host/wizard/x.json</c>)
/// alinmis olabilir; her iki durumda da izin verilen ilk kok klasorden itibaren okunur.
/// Kural disi bir yol icin null doner.
/// </summary>
private static string? NormalizeImportEntryPath(string? entryPath)
{
var normalized = entryPath?.Replace('\\', '/').Trim('/');
if (string.IsNullOrWhiteSpace(normalized) || normalized.Contains("..", StringComparison.Ordinal))
return null;
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
var start = Array.FindIndex(segments, s => ImportAllowedRootFolders.Contains(s, StringComparer.OrdinalIgnoreCase));
if (start < 0)
return null;
segments = segments[start..];
var isScriptFolder = segments[0].Equals(SeedPathResolver.SqlFolder, StringComparison.OrdinalIgnoreCase)
|| segments[0].Equals(SeedPathResolver.PostgresFolder, StringComparison.OrdinalIgnoreCase);
// sql/postgres altinda object|execute klasoru zorunludur; digerlerinde alt klasor yoktur.
if (isScriptFolder)
{
if (segments.Length != 3 || !ImportAllowedScriptFolders.Contains(segments[1], StringComparer.OrdinalIgnoreCase))
return null;
}
else if (segments.Length != 2)
{
return null;
}
var fileName = segments[^1];
var extension = Path.GetExtension(fileName);
var expectedExtension = isScriptFolder ? ".sql" : ".json";
if (!extension.Equals(expectedExtension, StringComparison.OrdinalIgnoreCase))
return null;
if (fileName.Length == extension.Length || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
return null;
return string.Join('/', segments);
}
/// <summary>
/// Karsilastirmayi satir sonu ve BOM farklarindan bagimsiz hale getirir; ayni icerigin
/// farkli platformlarda uretilmis hali catisma olarak gorunmez.
/// </summary>
private static string NormalizeImportText(byte[] bytes)
{
return Encoding.UTF8.GetString(bytes)
.TrimStart('')
.Replace("\r\n", "\n")
.Replace("\r", "\n")
.TrimEnd();
}
private string GetImportSessionPath(string importId)
{
return Path.Combine(SeedPathResolver.GetPath(_configuration, ImportSessionsFolder), importId);
}
private string GetExistingImportSessionPath(string importId)
{
// Oturum kimligi klasor adina donustugu icin yalnizca uretilen format kabul edilir.
if (string.IsNullOrWhiteSpace(importId) || !Guid.TryParseExact(importId, "N", out _))
throw new UserFriendlyException("Geçersiz import oturumu.");
var sessionPath = GetImportSessionPath(importId);
if (!Directory.Exists(sessionPath))
throw new UserFriendlyException("Import oturumu bulunamadı veya süresi dolmuş.");
return sessionPath;
}
private void DeleteImportSession(string sessionPath)
{
try
{
if (Directory.Exists(sessionPath))
Directory.Delete(sessionPath, recursive: true);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Wizard import session could not be deleted: {SessionPath}", sessionPath);
}
}
/// <summary>Yarim kalan (tarayici kapatilmis) oturumlarin klasorlerini temizler.</summary>
private void CleanupStaleImportSessions()
{
try
{
var root = SeedPathResolver.GetPath(_configuration, ImportSessionsFolder);
if (!Directory.Exists(root))
return;
foreach (var directory in Directory.GetDirectories(root))
{
if (Directory.GetLastWriteTimeUtc(directory) < DateTime.UtcNow.AddDays(-1))
Directory.Delete(directory, recursive: true);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Stale wizard import sessions could not be cleaned up.");
}
}
/// <summary> /// <summary>
/// Custom component seed dosyasini, bagimli oldugu component'leri ve bunlarin data source'larinin /// Custom component seed dosyasini, bagimli oldugu component'leri ve bunlarin data source'larinin
/// isaret ettigi crud seed dosyalarini toplar. Bagimlilik zinciri dongu icerse bile /// isaret ettigi crud seed dosyalarini toplar. Bagimlilik zinciri dongu icerse bile

View file

@ -1,4 +1,4 @@
{ {
"Languages": [ "Languages": [
{ {
"cultureName": "ar", "cultureName": "ar",
@ -26033,6 +26033,168 @@
"key": "App.PublicDemo.MailCompanyInfo", "key": "App.PublicDemo.MailCompanyInfo",
"en": "Company Information", "en": "Company Information",
"tr": "Firma Bilgileri" "tr": "Firma Bilgileri"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportButton",
"en": "Import",
"tr": "İçe Aktar"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportTitle",
"en": "Import wizard package (zip)",
"tr": "Wizard paketini içe aktar (zip)"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportAnalyzing",
"en": "Analyzing package...",
"tr": "Paket inceleniyor..."
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportNew",
"en": "new",
"tr": "yeni"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportIdentical",
"en": "no change",
"tr": "değişiklik yok"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportConflict",
"en": "conflict",
"tr": "çakışma"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportResolvedApply",
"en": "will be imported",
"tr": "aktarılacak"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportResolvedSkip",
"en": "skipped",
"tr": "atlanacak"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportInclude",
"en": "Include in import",
"tr": "İçe aktarmaya dahil et"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportShowIdentical",
"en": "identical files, show",
"tr": "aynı dosya var, göster"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportHideIdentical",
"en": "Hide identical files",
"tr": "Aynı dosyaları gizle"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportExistingSide",
"en": "Current file",
"tr": "Mevcut dosya"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportIncomingSide",
"en": "Incoming file (editable)",
"tr": "Gelen dosya (düzenlenebilir)"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportKeepExisting",
"en": "Keep current",
"tr": "Mevcut kalsın"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportUseThis",
"en": "Import this content",
"tr": "Bu içeriği aktar"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportResetIncoming",
"en": "Reset",
"tr": "Sıfırla"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportUnresolved",
"en": "conflicts must be resolved before import.",
"tr": "çakışma import öncesinde çözülmeli."
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportReady",
"en": "All files are reviewed, import can be started.",
"tr": "Tüm dosyalar gözden geçirildi, import başlatılabilir."
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportWarnings",
"en": "Skipped entries",
"tr": "Atlanan kayıtlar"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportStart",
"en": "Start import",
"tr": "İçe aktarmayı başlat"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportWriting",
"en": "Writing",
"tr": "Yazılıyor"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportCreated",
"en": "created",
"tr": "oluşturuldu"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportOverwritten",
"en": "overwritten",
"tr": "üzerine yazıldı"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportSuccess",
"en": "Import completed successfully.",
"tr": "İçe aktarma başarıyla tamamlandı."
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportRunMigrateHint",
"en": "Run DB Migrate to load the imported seed files into the database.",
"tr": "İçe aktarılan seed dosyalarının veritabanına işlenmesi için DB Migrate çalıştırın."
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportRolledBack",
"en": "Import failed, all written files were restored.",
"tr": "İçe aktarma başarısız oldu, yazılan tüm dosyalar geri alındı."
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportRollbackFailed",
"en": "Rollback could not be completed",
"tr": "Geri alma tamamlanamadı"
} }
] ]
} }

View file

@ -2480,6 +2480,33 @@
"MultiTenancySide": 2, "MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs" "MenuGroup": "Erp|Kurs"
}, },
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.Components.Create",
"ParentName": "App.DeveloperKit.Components",
"DisplayName": "App.Platform.Create",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.Components.Update",
"ParentName": "App.DeveloperKit.Components",
"DisplayName": "App.Platform.Update",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.Components.Delete",
"ParentName": "App.DeveloperKit.Components",
"DisplayName": "App.Platform.Delete",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{ {
"GroupName": "App.Saas", "GroupName": "App.Saas",
"Name": "App.DeveloperKit.DynamicServices", "Name": "App.DeveloperKit.DynamicServices",
@ -2561,6 +2588,51 @@
"MultiTenancySide": 2, "MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs" "MenuGroup": "Erp|Kurs"
}, },
{
"GroupName": "App.Saas",
"Name": "App.Listforms.Wizard.Create",
"ParentName": "App.Listforms.Wizard",
"DisplayName": "App.Platform.Create",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Listforms.Wizard.Update",
"ParentName": "App.Listforms.Wizard",
"DisplayName": "App.Platform.Update",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Listforms.Wizard.Delete",
"ParentName": "App.Listforms.Wizard",
"DisplayName": "App.Platform.Delete",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Listforms.Wizard.Export",
"ParentName": "App.Listforms.Wizard",
"DisplayName": "App.Platform.Export",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Listforms.Wizard.Import",
"ParentName": "App.Listforms.Wizard",
"DisplayName": "App.Platform.Import",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{ {
"GroupName": "App.Administration", "GroupName": "App.Administration",
"Name": "App.Setting", "Name": "App.Setting",

View file

@ -0,0 +1,14 @@
namespace Sozsoft.Platform.Enums;
/// <summary>Import edilen zip girdisinin hedef kapsam klasorundeki dosyayla karsilastirma sonucu.</summary>
public enum WizardImportEntryStatusEnum
{
/// <summary>Hedefte ayni yolda dosya yok; dogrudan kopyalanir.</summary>
New = 0,
/// <summary>Hedefteki dosya ile icerik ayni; kullaniciya sorulmaz, islem yapilmaz.</summary>
Identical = 1,
/// <summary>Hedefte farkli icerikli bir dosya var; kullanici karsilastirip karar vermelidir.</summary>
Conflict = 2,
}

View file

@ -442,6 +442,19 @@ public static class PlatformConsts
public const string Listform = Default + ".Listform"; public const string Listform = Default + ".Listform";
public const string ListformField = Default + ".ListformField"; public const string ListformField = Default + ".ListformField";
public const string Chart = Default + ".Chart"; public const string Chart = Default + ".Chart";
/// <summary>
/// Wizard dosya yoneticisinin aksiyon yetkileri. Hepsi <see cref="Wizard"/> altindadir;
/// <see cref="Wizard"/> ekrani gorme (okuma) yetkisidir.
/// </summary>
public static class Wizards
{
public const string Create = Wizard + ".Create";
public const string Update = Wizard + ".Update";
public const string Delete = Wizard + ".Delete";
public const string Export = Wizard + ".Export";
public const string Import = Wizard + ".Import";
}
} }
public static class Notifications public static class Notifications
@ -486,6 +499,19 @@ public static class PlatformConsts
public const string Put = CustomEndpoints + ".Put"; public const string Put = CustomEndpoints + ".Put";
public const string Remove = CustomEndpoints + ".Remove"; public const string Remove = CustomEndpoints + ".Remove";
/// <summary>
/// Custom component yoneticisinin yetkileri. <see cref="Component"/> ekrani gorme
/// (okuma) yetkisidir; aksiyonlar onun altindaki alt yetkileri ister.
/// </summary>
public static class Components
{
public const string Component = Default + ".Components";
public const string Create = Component + ".Create";
public const string Update = Component + ".Update";
public const string Delete = Component + ".Delete";
}
public static class DynamicServices public static class DynamicServices
{ {
public const string DynamicService = Default + ".DynamicServices"; public const string DynamicService = Default + ".DynamicServices";

View file

@ -0,0 +1,148 @@
{
"Wizard": {
"ComponentKind": 1,
"WizardName": "RoleList",
"ListFormCode": "App.Wizard.RoleList",
"MenuCode": "App.Wizard.RoleList",
"MenuOrder": 1,
"CustomComponentName": "RoleListComponent",
"MenuUrl": "/admin/roles-list",
"IsTenant": false,
"IsBranch": false,
"IsOrganizationUnit": false,
"AllowAdding": true,
"AllowUpdating": true,
"AllowDeleting": true,
"AllowDetail": false,
"ConfirmDelete": true,
"DefaultLayout": "grid",
"Grid": true,
"Card": true,
"Pivot": true,
"Tree": false,
"Chart": true,
"Gantt": false,
"Scheduler": false,
"Todo": false,
"LanguageTextMenuEn": "Role List",
"LanguageTextMenuTr": "Role List",
"LanguageTextTitleEn": "Role List",
"LanguageTextTitleTr": "Role List",
"LanguageTextDescEn": "Role List",
"LanguageTextDescTr": "Role List",
"LanguageTextMenuParentEn": "Sample",
"LanguageTextMenuParentTr": "Sample",
"PermissionGroupName": "App.Wizard.Sample",
"MenuParentCode": "App.Wizard.Sample",
"MenuParentShortName": "Sam",
"MenuParentIcon": "FcBookmark",
"MenuIcon": "FcBiohazard",
"DataSourceCode": "Default",
"DataSourceConnectionString": "",
"SelectCommandType": 1,
"SelectCommand": "",
"KeyFieldName": "",
"KeyFieldDbSourceType": 11,
"TreeOptionDto": {
"KeyExpr": "",
"ParentIdExpr": "",
"HasItemsExpr": "",
"RootValue": "",
"ExpandedRowKeys": [],
"AutoExpandAll": false,
"RecursiveSelection": false,
"TitleExpr": null,
"StartExpr": null,
"EndExpr": null,
"ProgressExpr": null
},
"GanttOptionDto": {
"KeyExpr": "",
"ParentIdExpr": "",
"HasItemsExpr": "",
"RootValue": "",
"ExpandedRowKeys": [],
"AutoExpandAll": false,
"RecursiveSelection": false,
"TitleExpr": "",
"ScaleType": "weeks",
"StartExpr": "",
"EndExpr": "",
"ProgressExpr": "",
"AllowEditing": false,
"AllowTaskAdding": false,
"AllowTaskUpdating": false,
"AllowTaskResourceUpdating": false,
"AllowTaskDeleting": false,
"AllowDependencyAdding": false,
"AllowDependencyDeleting": false,
"AllowResourceAdding": false,
"AllowResourceUpdating": false,
"AllowResourceDeleting": false
},
"SchedulerOptionDto": {
"TextExpr": "",
"UserNameExpr": null,
"DescriptionExpr": null,
"StartDateExpr": "",
"EndDateExpr": "",
"AllDayExpr": "",
"RecurrenceRuleExpr": "",
"RecurrenceExceptionExpr": "",
"StartDayHour": 8,
"EndDayHour": 20,
"DefaultView": "week",
"ShowAllDayPanel": true,
"CellDuration": 30,
"FirstDayOfWeek": 1,
"CrossScrollingEnabled": false,
"AllowResizing": false,
"AllowDragging": false,
"AllowDeleting": false,
"AllowEditing": false,
"AllowAdding": false
},
"TodoOptionDto": {
"TitleExpr": "",
"StatusExpr": "",
"DescriptionExpr": "",
"DueDateExpr": "",
"TagExpr": "",
"AssigneeExpr": "",
"PriorityExpr": "",
"CompletedExpr": "",
"OrderExpr": "",
"StatusOrder": "Backlog, Ready to Start, In Progress, Code Review, Testing, Staging",
"AllowDragging": true
},
"WorkflowDto": {
"ApprovalUserFieldName": "",
"ApprovalDateFieldName": "",
"ApprovalStatusFieldName": "",
"ApprovalDescriptionFieldName": "",
"ApprovalIsFilterUserName": false,
"ApprovalIsResetWorkflow": false,
"Criteria": []
},
"Groups": [],
"SubForms": [],
"Widgets": []
},
"IsDeletedField": false,
"IsCreatedField": false,
"InsertedRecords": {
"LanguageKeys": [
"App.Wizard.RoleList"
],
"PermissionGroupNames": [
"App.Wizard.Sample"
],
"PermissionNames": [
"App.Wizard.RoleList"
],
"MenuCodes": [
"App.Wizard.RoleList"
],
"DataSourceCodes": []
}
}

View file

@ -15,3 +15,31 @@ export const PUBLIC_PAGE_DESIGN = {
SERVICES: 'App.Services.Design', SERVICES: 'App.Services.Design',
CONTACT: 'App.Contact.Design', CONTACT: 'App.Contact.Design',
} as const } as const
/**
* Wizard dosya yöneticisi yetkileri. `PermissionsData.json` içinde tanımlıdır ve seeder ile
* yönetilir; buradaki kontroller yalnızca butonları gizler, asıl kontrol
* `ListFormWizardAppService` üzerindedir.
*/
export const WIZARD_PERMISSION = {
/** Ekranı görme (okuma) yetkisi; alt yetkilerin tamamının üstüdür. */
DEFAULT: 'App.Listforms.Wizard',
CREATE: 'App.Listforms.Wizard.Create',
UPDATE: 'App.Listforms.Wizard.Update',
DELETE: 'App.Listforms.Wizard.Delete',
EXPORT: 'App.Listforms.Wizard.Export',
IMPORT: 'App.Listforms.Wizard.Import',
} as const
/**
* Custom component yöneticisi yetkileri. `PermissionsData.json` içinde tanımlıdır ve seeder ile
* yönetilir; buradaki kontroller yalnızca butonları gizler, asıl kontrol
* `CustomComponentAppService` üzerindeki ABP policy adlarındadır.
*/
export const COMPONENT_PERMISSION = {
/** Ekranı görme (okuma) yetkisi; alt yetkilerin tamamının üstüdür. */
DEFAULT: 'App.DeveloperKit.Components',
CREATE: 'App.DeveloperKit.Components.Create',
UPDATE: 'App.DeveloperKit.Components.Update',
DELETE: 'App.DeveloperKit.Components.Delete',
} as const

View file

@ -36,6 +36,12 @@ export enum WizardComponentKindEnum {
export interface ListFormWizardDto { export interface ListFormWizardDto {
componentKind: WizardComponentKindEnum componentKind: WizardComponentKindEnum
/**
* Düzenleme modunda, yerine geçilecek seed dosyasının adı. Doluysa sunucu önce bu dosyayı
* ve ürettiği kayıtları siler, sonra yenisini üretir; istek güncelleme yetkisiyle çalışır.
*/
editFileName?: string
wizardName: string wizardName: string
listFormCode: string listFormCode: string
menuCode: string menuCode: string
@ -149,3 +155,38 @@ export interface WizardSeedFileDto {
isCreatedField: boolean isCreatedField: boolean
insertedRecords: WizardInsertedRecordsDto insertedRecords: WizardInsertedRecordsDto
} }
/** Import edilen dosyanin hedefteki karsiligiyla karsilastirma sonucu; backend enum'u ile birebir. */
export enum WizardImportEntryStatusEnum {
New = 0,
Identical = 1,
Conflict = 2,
}
export interface WizardImportEntryDto {
/** Kapsam klasorune goreli yol, ornegin `custom/Orders.json`. */
path: string
folder: string
fileName: string
status: WizardImportEntryStatusEnum
incomingSize: number
existingSize: number
/** Yalnizca Conflict durumunda doludur. */
incomingContent?: string
/** Yalnizca Conflict durumunda doludur. */
existingContent?: string
}
export interface WizardImportAnalysisDto {
importId: string
/** Dosyalarin yazilacagi kapsam klasoru (`host` ya da `tenants/{tenantId}`). */
scopeFolder: string
entries: WizardImportEntryDto[]
warnings: string[]
}
export interface WizardImportApplyResultDto {
path: string
overwritten: boolean
size: number
}

View file

@ -1,4 +1,10 @@
import { ListFormWizardDto, WizardFileInfoDto, WizardSeedFileDto } from '@/proxy/admin/wizard/models' import {
ListFormWizardDto,
WizardFileInfoDto,
WizardImportAnalysisDto,
WizardImportApplyResultDto,
WizardSeedFileDto,
} from '@/proxy/admin/wizard/models'
import apiService from './api.service' import apiService from './api.service'
export const postListFormWizard = (input: ListFormWizardDto) => export const postListFormWizard = (input: ListFormWizardDto) =>
@ -51,4 +57,46 @@ export const downloadBlob = (blob: Blob, fileName: string) => {
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
} }
/**
* Zip'i sunucudaki gecici bir oturuma acar ve her dosyayi hedefteki karsiligiyla karsilastirir.
* Hicbir hedef dosyaya dokunmaz; donen `importId` uygulama adimlarinda kullanilir.
*/
export const analyzeWizardImport = (file: File) => {
const formData = new FormData()
formData.append('file', file)
formData.append('FileName', file.name)
formData.append('ContentType', file.type || 'application/zip')
formData.append('ContentLength', file.size.toString())
return apiService.fetchData<WizardImportAnalysisDto>({
method: 'POST',
url: `/api/app/list-form-wizard/analyze-import`,
data: formData as any,
})
}
/** Tek bir dosyayi hedefe yazar. `content` verilmezse zip'ten gelen icerik oldugu gibi yazilir. */
export const applyWizardImport = (importId: string, path: string, content?: string) =>
apiService.fetchData<WizardImportApplyResultDto>({
method: 'POST',
url: `/api/app/list-form-wizard/apply-import`,
data: { importId, path, content: content ?? null },
})
/** Import oturumunu kapatir; staging ve yedek klasorleri silinir. */
export const completeWizardImport = (importId: string) =>
apiService.fetchData({
method: 'POST',
url: `/api/app/list-form-wizard/complete-import`,
params: { importId },
})
/** O ana kadar yazilmis dosyalari geri alir ve oturumu kapatir. */
export const rollbackWizardImport = (importId: string) =>
apiService.fetchData({
method: 'POST',
url: `/api/app/list-form-wizard/rollback-import`,
params: { importId },
})

View file

@ -15,31 +15,27 @@ import { getDataSources } from '@/services/data-source.service'
import { sqlObjectManagerService } from '@/services/sql-query-manager.service' import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
import type { SqlObjectExplorerDto, DatabaseColumnDto } from '@/proxy/sql-query-manager/models' import type { SqlObjectExplorerDto, DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import { MenuItem } from '@/proxy/menus/menu' import { MenuItem } from '@/proxy/menus/menu'
import WizardStep1, { // Adım bileşenleri, sihirbazda göründükleri sırayla.
import WizardStepMenu, {
MenuTreeNode, MenuTreeNode,
buildMenuTree, buildMenuTree,
filterNonLinkNodes, filterNonLinkNodes,
findRootCode, findRootCode,
} from './WizardStep1' } from './WizardStepMenu'
import WizardStep2 from './WizardStep2' import WizardStepDataSettings from './WizardStepDataSettings'
import WizardStep3, { WizardGroup, WizardGroupItem } from './WizardStep3' import WizardStepFields, { WizardGroup, WizardGroupItem } from './WizardStepFields'
import WizardStep7 from './WizardStep7' import WizardStepSubForms from './WizardStepSubForms'
import WizardStep8 from './WizardStep8' import WizardStepWidgets from './WizardStepWidgets'
import WizardStep9 from './WizardStep9' import WizardStepWorkflow from './WizardStepWorkflow'
import WizardStep10 from './WizardStep10' import WizardStepTodoLayout from './WizardStepTodoLayout'
import WizardStep11 from './WizardStep11' import WizardStepTreeLayout from './WizardStepTreeLayout'
import WizardStep4 from './WizardStep4' import WizardStepGanttLayout from './WizardStepGanttLayout'
import WizardStep5 from './WizardStep5' import WizardStepSchedulerLayout from './WizardStepSchedulerLayout'
import WizardStep6 from './WizardStep6' import WizardStepDeploy from './WizardStepDeploy'
import { Container } from '@/components/shared' import { Container } from '@/components/shared'
import { sqlDataTypeToDbType } from '../edit/options' import { sqlDataTypeToDbType } from '../edit/options'
import { useStoreActions } from '@/store/store' import { useStoreActions } from '@/store/store'
import { import { getWizardFile, getWizardFiles, postListFormWizard } from '@/services/wizard.service'
deleteWizardFile,
getWizardFile,
getWizardFiles,
postListFormWizard,
} from '@/services/wizard.service'
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models' import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
import WizardStepComponent from './WizardStepComponent' import WizardStepComponent from './WizardStepComponent'
import WizardStepCustomComponent from './WizardStepCustomComponent' import WizardStepCustomComponent from './WizardStepCustomComponent'
@ -451,8 +447,7 @@ const Wizard = () => {
formikRef.current?.setFieldValue('isBranch', colNames.has('branchid')) formikRef.current?.setFieldValue('isBranch', colNames.has('branchid'))
// Anahtar alan: tabloda Id sutunu varsa her zaman odur; sutun sirasi anahtari // Anahtar alan: tabloda Id sutunu varsa her zaman odur; sutun sirasi anahtari
// belirlemez. Id yoksa son care olarak ilk sutuna dusulur. // belirlemez. Id yoksa son care olarak ilk sutuna dusulur.
const keyColumn = const keyColumn = cols.find((c) => c.columnName.toLowerCase() === 'id') ?? cols[0]
cols.find((c) => c.columnName.toLowerCase() === 'id') ?? cols[0]
if (keyColumn) { if (keyColumn) {
formikRef.current?.setFieldValue('keyFieldName', keyColumn.columnName) formikRef.current?.setFieldValue('keyFieldName', keyColumn.columnName)
formikRef.current?.setFieldValue( formikRef.current?.setFieldValue(
@ -949,16 +944,16 @@ const Wizard = () => {
const values = formik.values const values = formik.values
const isCustom = values.componentKind === WizardComponentKindEnum.Custom const isCustom = values.componentKind === WizardComponentKindEnum.Custom
try { // Edit modunda eski dosyanın silinmesini (ve DB kayıtlarının temizlenmesini) sunucu yapar;
// Edit modunda: önce eski dosyayı sil (DB kayıtlarını temizler) // istek `editFileName` ile geldiğinde güncelleme yetkisiyle çalışır.
if (isEditMode && editFileName) { const editing = isEditMode && editFileName ? { editFileName } : {}
await deleteWizardFile(editFileName)
}
try {
// Custom yolunda ListForm'a ait hiçbir koleksiyon gönderilmez. // Custom yolunda ListForm'a ait hiçbir koleksiyon gönderilmez.
if (isCustom) { if (isCustom) {
await postListFormWizard({ await postListFormWizard({
...values, ...values,
...editing,
groups: [], groups: [],
subForms: [], subForms: [],
widgets: [], widgets: [],
@ -979,6 +974,7 @@ const Wizard = () => {
await postListFormWizard({ await postListFormWizard({
...values, ...values,
...editing,
groups: editingGroups.map((g) => ({ groups: editingGroups.map((g) => ({
caption: g.caption, caption: g.caption,
colCount: g.colCount, colCount: g.colCount,
@ -1173,7 +1169,7 @@ const Wizard = () => {
{/* ─── Step 3: Menu ───────────────────────────────────── */} {/* ─── Step 3: Menu ───────────────────────────────────── */}
{resolvedStep === STEP.menu && ( {resolvedStep === STEP.menu && (
<WizardStep1 <WizardStepMenu
values={values} values={values}
errors={errors} errors={errors}
touched={touched} touched={touched}
@ -1209,7 +1205,7 @@ const Wizard = () => {
{/* ─── Step 4: Data Settings ───────────────────────────── */} {/* ─── Step 4: Data Settings ───────────────────────────── */}
{resolvedStep === STEP.settings && ( {resolvedStep === STEP.settings && (
<WizardStep2 <WizardStepDataSettings
values={values} values={values}
errors={errors} errors={errors}
touched={touched} touched={touched}
@ -1240,7 +1236,7 @@ const Wizard = () => {
{/* ─── Step 5: List Form Fields ───────────────────────────── */} {/* ─── Step 5: List Form Fields ───────────────────────────── */}
{resolvedStep === STEP.fields && ( {resolvedStep === STEP.fields && (
<WizardStep3 <WizardStepFields
selectedColumns={selectedColumns} selectedColumns={selectedColumns}
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
groups={editingGroups} groups={editingGroups}
@ -1255,7 +1251,7 @@ const Wizard = () => {
{/* ─── Step 6: Sub Forms ───────────────────────────── */} {/* ─── Step 6: Sub Forms ───────────────────────────── */}
{resolvedStep === STEP.subForms && ( {resolvedStep === STEP.subForms && (
<WizardStep4 <WizardStepSubForms
subForms={subForms} subForms={subForms}
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
translate={translate} translate={translate}
@ -1267,7 +1263,7 @@ const Wizard = () => {
{/* ─── Step 7: Widgets ───────────────────────────── */} {/* ─── Step 7: Widgets ───────────────────────────── */}
{resolvedStep === STEP.widgets && ( {resolvedStep === STEP.widgets && (
<WizardStep5 <WizardStepWidgets
widgets={widgets} widgets={widgets}
translate={translate} translate={translate}
onChange={setWidgets} onChange={setWidgets}
@ -1278,7 +1274,7 @@ const Wizard = () => {
{/* ─── Step 8: Workflow ───────────────────────────── */} {/* ─── Step 8: Workflow ───────────────────────────── */}
{resolvedStep === STEP.workflow && ( {resolvedStep === STEP.workflow && (
<WizardStep6 <WizardStepWorkflow
listFormCode={values.listFormCode} listFormCode={values.listFormCode}
workflow={workflow} workflow={workflow}
criteria={criteria} criteria={criteria}
@ -1293,7 +1289,7 @@ const Wizard = () => {
{/* ─── Step 9: Todo / Kanban ────────────────────── */} {/* ─── Step 9: Todo / Kanban ────────────────────── */}
{resolvedStep === STEP.todo && values.todo && ( {resolvedStep === STEP.todo && values.todo && (
<WizardStep7 <WizardStepTodoLayout
selectedColumns={selectedColumns} selectedColumns={selectedColumns}
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
translate={translate} translate={translate}
@ -1304,7 +1300,7 @@ const Wizard = () => {
{/* ─── Step 10: Tree ─────────────────────────────── */} {/* ─── Step 10: Tree ─────────────────────────────── */}
{resolvedStep === STEP.tree && values.tree && ( {resolvedStep === STEP.tree && values.tree && (
<WizardStep8 <WizardStepTreeLayout
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
translate={translate} translate={translate}
onBack={() => goToAdjacentStep(-1)} onBack={() => goToAdjacentStep(-1)}
@ -1314,7 +1310,7 @@ const Wizard = () => {
{/* ─── Step 11: Gantt ────────────────────────────── */} {/* ─── Step 11: Gantt ────────────────────────────── */}
{resolvedStep === STEP.gantt && values.gantt && ( {resolvedStep === STEP.gantt && values.gantt && (
<WizardStep9 <WizardStepGanttLayout
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
translate={translate} translate={translate}
onBack={() => goToAdjacentStep(-1)} onBack={() => goToAdjacentStep(-1)}
@ -1324,7 +1320,7 @@ const Wizard = () => {
{/* ─── Step 12: Scheduler ───────────────────────── */} {/* ─── Step 12: Scheduler ───────────────────────── */}
{resolvedStep === STEP.scheduler && values.scheduler && ( {resolvedStep === STEP.scheduler && values.scheduler && (
<WizardStep10 <WizardStepSchedulerLayout
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
translate={translate} translate={translate}
onBack={() => goToAdjacentStep(-1)} onBack={() => goToAdjacentStep(-1)}
@ -1334,7 +1330,7 @@ const Wizard = () => {
{/* ─── Step 13: Deploy ──────────────────────────── */} {/* ─── Step 13: Deploy ──────────────────────────── */}
{resolvedStep === STEP.deploy && ( {resolvedStep === STEP.deploy && (
<WizardStep11 <WizardStepDeploy
values={values} values={values}
wizardName={values.wizardName} wizardName={values.wizardName}
selectedColumns={selectedColumns} selectedColumns={selectedColumns}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import classNames from 'classnames' import classNames from 'classnames'
import { Button, Dialog, Input, Notification, toast } from '@/components/ui' import { Button, Dialog, Input, Notification, toast } from '@/components/ui'
@ -17,6 +17,7 @@ import {
FaLink, FaLink,
FaExternalLinkAlt, FaExternalLinkAlt,
FaFileExport, FaFileExport,
FaFileImport,
} from 'react-icons/fa' } from 'react-icons/fa'
import DbMigrateButton from '@/components/shared/DbMigrateButton' import DbMigrateButton from '@/components/shared/DbMigrateButton'
import { import {
@ -31,6 +32,9 @@ import { useStoreState } from '@/store/store'
import { ROUTES_ENUM } from '@/routes/route.constant' import { ROUTES_ENUM } from '@/routes/route.constant'
import { WizardComponentKindEnum, WizardFileInfoDto } from '@/proxy/admin/wizard/models' import { WizardComponentKindEnum, WizardFileInfoDto } from '@/proxy/admin/wizard/models'
import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config' import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config'
import { usePermission } from '@/utils/hooks/usePermission'
import { WIZARD_PERMISSION } from '@/constants/permission.constant'
import WizardImportDialog from './WizardImportDialog'
interface ConfirmState { interface ConfirmState {
fileName: string fileName: string
@ -50,6 +54,16 @@ const WizardFileManager = () => {
const [exportingFile, setExportingFile] = useState<string | null>(null) const [exportingFile, setExportingFile] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmState | null>(null) const [confirm, setConfirm] = useState<ConfirmState | null>(null)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [importFile, setImportFile] = useState<File | null>(null)
const importInputRef = useRef<HTMLInputElement>(null)
// Butonlar yalnizca gizlenir; asil kontrol ListFormWizardAppService'te yapilir.
const { checkPermission } = usePermission()
const canCreate = checkPermission(WIZARD_PERMISSION.CREATE)
const canUpdate = checkPermission(WIZARD_PERMISSION.UPDATE)
const canDelete = checkPermission(WIZARD_PERMISSION.DELETE)
const canExport = checkPermission(WIZARD_PERMISSION.EXPORT)
const canImport = checkPermission(WIZARD_PERMISSION.IMPORT)
const filteredFiles = useMemo(() => { const filteredFiles = useMemo(() => {
const q = search.trim().toLowerCase() const q = search.trim().toLowerCase()
@ -104,6 +118,14 @@ const WizardFileManager = () => {
} }
} }
// Zip secildikten sonra tum akis WizardImportDialog icinde yurur; ayni dosya yeniden
// secilebilsin diye input degeri her seferinde sifirlanir.
const handleImportFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (file) setImportFile(file)
}
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!confirm) return if (!confirm) return
setDeletingFile(confirm.fileName) setDeletingFile(confirm.fileName)
@ -166,18 +188,44 @@ const WizardFileManager = () => {
> >
<FaSync /> <FaSync />
</Button> </Button>
{canImport && (
<>
<Button
size="sm"
variant="default"
type="button"
title={translate('::App.ListForm.WizardImportTitle')}
className="flex items-center"
onClick={() => importInputRef.current?.click()}
>
<FaFileImport className="sm:mr-1" />
<span className="hidden sm:inline">
{translate('::App.ListForm.WizardImportButton')}
</span>
</Button>
<input
ref={importInputRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={handleImportFileChange}
/>
</>
)}
<DbMigrateButton hideLabelOnMobile /> <DbMigrateButton hideLabelOnMobile />
<Button {canCreate && (
size="sm" <Button
variant="solid" size="sm"
onClick={() => navigate(ROUTES_ENUM.protected.saas.listFormManagement.wizard)} variant="solid"
className="flex items-center" className="flex items-center"
> onClick={() => navigate(ROUTES_ENUM.protected.saas.listFormManagement.wizard)}
<FaPlus className="mr-1" /> >
<span className="hidden sm:inline"> <FaPlus className="mr-1" />
{translate('::App.ListFormWizard.AddNewRecord')} <span className="hidden sm:inline">
</span> {translate('::App.ListFormWizard.AddNewRecord')}
</Button> </span>
</Button>
)}
</div> </div>
</div> </div>
@ -280,44 +328,50 @@ const WizardFileManager = () => {
<FaExternalLinkAlt /> <FaExternalLinkAlt />
</Button> </Button>
)} )}
<Button {canExport && (
size="sm" <Button
variant="plain" size="sm"
className="text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-900/20" variant="plain"
type="button" className="text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-900/20"
title={translate('::App.ListForm.WizardFileExport')} type="button"
loading={exportingFile === f.fileName} title={translate('::App.ListForm.WizardFileExport')}
onClick={() => handleExport(f)} loading={exportingFile === f.fileName}
> onClick={() => handleExport(f)}
<FaFileExport /> >
</Button> <FaFileExport />
<Button </Button>
size="sm" )}
variant="plain" {canUpdate && (
className="text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/20" <Button
type="button" size="sm"
title={translate('::App.Platform.Edit')} variant="plain"
onClick={() => className="text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/20"
navigate(ROUTES_ENUM.protected.saas.listFormManagement.wizard, { type="button"
state: { editFileName: f.fileName }, title={translate('::App.Platform.Edit')}
}) onClick={() =>
} navigate(ROUTES_ENUM.protected.saas.listFormManagement.wizard, {
> state: { editFileName: f.fileName },
<FaEdit /> })
</Button> }
<Button >
size="sm" <FaEdit />
variant="plain" </Button>
className="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20" )}
type="button" {canDelete && (
title={translate('::App.Platform.Delete')} <Button
loading={deletingFile === f.fileName} size="sm"
onClick={() => variant="plain"
setConfirm({ fileName: f.fileName, wizardName: f.wizardName || f.fileName }) className="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
} type="button"
> title={translate('::App.Platform.Delete')}
<FaTrash /> loading={deletingFile === f.fileName}
</Button> onClick={() =>
setConfirm({ fileName: f.fileName, wizardName: f.wizardName || f.fileName })
}
>
<FaTrash />
</Button>
)}
</div> </div>
</div> </div>
) )
@ -325,6 +379,14 @@ const WizardFileManager = () => {
</div> </div>
</div> </div>
{importFile && (
<WizardImportDialog
file={importFile}
onClose={() => setImportFile(null)}
onImported={loadFiles}
/>
)}
{/* Delete Confirm Dialog */} {/* Delete Confirm Dialog */}
<Dialog <Dialog
isOpen={confirm !== null} isOpen={confirm !== null}

View file

@ -0,0 +1,662 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import classNames from 'classnames'
import { DiffEditor } from '@monaco-editor/react'
import { Button, Dialog, Progress } from '@/components/ui'
import {
FaCheck,
FaCheckCircle,
FaCodeBranch,
FaExclamationTriangle,
FaFileAlt,
FaFileImport,
FaPlusCircle,
FaTimes,
FaTimesCircle,
FaUndo,
} from 'react-icons/fa'
import {
analyzeWizardImport,
applyWizardImport,
completeWizardImport,
rollbackWizardImport,
} from '@/services/wizard.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useStoreState } from '@/store/store'
import {
WizardImportAnalysisDto,
WizardImportEntryDto,
WizardImportEntryStatusEnum,
} from '@/proxy/admin/wizard/models'
interface WizardImportDialogProps {
file: File | null
onClose: () => void
/** En az bir dosya yazildiginda tetiklenir; liste yenilenir. */
onImported: () => void
}
/** Kullanicinin bir dosya icin verdigi karar. */
interface EntryDecision {
/** false ise dosya hedefe yazilmaz. */
apply: boolean
/** Catisma ekraninda karar verildi mi; verilmeden import baslatilamaz. */
resolved: boolean
/** Diff ekraninda birlestirilmis icerik; yalnizca catismali dosyalarda dolar. */
content?: string
}
type ImportPhase = 'analyzing' | 'review' | 'running' | 'finished' | 'rolledBack'
interface AppliedRow {
path: string
ok: boolean
overwritten: boolean
message?: string
}
const statusOrder: Record<WizardImportEntryStatusEnum, number> = {
[WizardImportEntryStatusEnum.Conflict]: 0,
[WizardImportEntryStatusEnum.New]: 1,
[WizardImportEntryStatusEnum.Identical]: 2,
}
const languageOf = (fileName: string) => (fileName.toLowerCase().endsWith('.sql') ? 'sql' : 'json')
const formatSize = (size: number) => {
if (!size) return '0 B'
if (size < 1024) return `${size} B`
return `${(size / 1024).toFixed(1)} KB`
}
/**
* Wizard export zip'ini kapsam klasorune aktarir.
* Akis: analiz catisma cozumu (diff) toplu yazma. Yazma sirasinda bir hata olursa
* o ana kadar yazilan tum dosyalar sunucudaki yedeklerden geri alinir.
*/
const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogProps) => {
const { translate } = useLocalization()
const mode = useStoreState((state) => state.theme.mode)
const [phase, setPhase] = useState<ImportPhase>('analyzing')
const [analysis, setAnalysis] = useState<WizardImportAnalysisDto | null>(null)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<string, EntryDecision>>({})
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [applied, setApplied] = useState<AppliedRow[]>([])
const [currentPath, setCurrentPath] = useState<string | null>(null)
const [showIdentical, setShowIdentical] = useState(false)
const [editorEpoch, setEditorEpoch] = useState(0)
// Diff editorunde yazilan icerik her tusa basista state'e yazilmaz; karar verilirken okunur.
const draftRef = useRef<string>('')
const entries = useMemo(() => {
const list = [...(analysis?.entries ?? [])]
list.sort(
(a, b) =>
statusOrder[a.status] - statusOrder[b.status] ||
a.path.localeCompare(b.path, undefined, { sensitivity: 'base' }),
)
return list
}, [analysis])
const conflicts = useMemo(
() => entries.filter((e) => e.status === WizardImportEntryStatusEnum.Conflict),
[entries],
)
const unresolvedCount = useMemo(
() => conflicts.filter((e) => !decisions[e.path]?.resolved).length,
[conflicts, decisions],
)
const applyList = useMemo(
() => entries.filter((e) => decisions[e.path]?.apply),
[entries, decisions],
)
const visibleEntries = useMemo(
() =>
showIdentical
? entries
: entries.filter((e) => e.status !== WizardImportEntryStatusEnum.Identical),
[entries, showIdentical],
)
const identicalCount = useMemo(
() => entries.filter((e) => e.status === WizardImportEntryStatusEnum.Identical).length,
[entries],
)
const selectedEntry = useMemo(
() => entries.find((e) => e.path === selectedPath) ?? null,
[entries, selectedPath],
)
// Analiz: zip sunucuda gecici bir oturuma acilir, hedef klasore henuz dokunulmaz.
useEffect(() => {
let cancelled = false
const run = async () => {
if (!file) return
setPhase('analyzing')
setError(null)
try {
const res = await analyzeWizardImport(file)
if (cancelled) return
const data = res.data
const initial: Record<string, EntryDecision> = {}
data.entries.forEach((entry) => {
initial[entry.path] = {
// Yeni dosyalar sorulmadan kopyalanir, ayni olanlar atlanir, catismalilar karar bekler.
apply: entry.status === WizardImportEntryStatusEnum.New,
resolved: entry.status !== WizardImportEntryStatusEnum.Conflict,
content:
entry.status === WizardImportEntryStatusEnum.Conflict
? entry.incomingContent
: undefined,
}
})
setAnalysis(data)
setDecisions(initial)
setPhase('review')
const firstConflict = data.entries.find(
(e) => e.status === WizardImportEntryStatusEnum.Conflict,
)
if (firstConflict) setSelectedPath(firstConflict.path)
} catch (err: any) {
if (cancelled) return
setError(err?.response?.data?.error?.message ?? err?.message ?? 'Unknown error')
setPhase('review')
}
}
run()
return () => {
cancelled = true
}
}, [file])
// Diff editorunun tasidigi taslak, secim degistikce ilgili dosyanin son haline doner.
useEffect(() => {
if (!selectedEntry) {
draftRef.current = ''
return
}
draftRef.current = decisions[selectedEntry.path]?.content ?? selectedEntry.incomingContent ?? ''
// decisions bagimliligi kasitli olarak disarida: her tus vurusunda editor sifirlanmamalidir.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPath])
const decide = useCallback((path: string, decision: EntryDecision) => {
setDecisions((prev) => ({ ...prev, [path]: decision }))
}, [])
const goToNextConflict = useCallback(
(fromPath: string) => {
const index = conflicts.findIndex((c) => c.path === fromPath)
const next = conflicts.slice(index + 1).find((c) => !decisions[c.path]?.resolved)
setSelectedPath(next ? next.path : null)
},
[conflicts, decisions],
)
const handleTakeIncoming = (entry: WizardImportEntryDto) => {
decide(entry.path, { apply: true, resolved: true, content: draftRef.current })
goToNextConflict(entry.path)
}
const handleKeepExisting = (entry: WizardImportEntryDto) => {
decide(entry.path, { apply: false, resolved: true })
goToNextConflict(entry.path)
}
/** Editordeki elle yapilan degisiklikleri atip zip'ten gelen icerige doner. */
const handleResetDraft = (entry: WizardImportEntryDto) => {
draftRef.current = entry.incomingContent ?? ''
decide(entry.path, {
...(decisions[entry.path] ?? { apply: true, resolved: false }),
content: entry.incomingContent,
})
// Editor icerigi prop ile ayni kalabilecegi icin bilesen key ile yeniden kurulur.
setEditorEpoch((v) => v + 1)
}
/** Dosyalari sirayla yazar; ilk hatada tum toplu islem geri alinir. */
const handleStart = async () => {
if (!analysis) return
setPhase('running')
setApplied([])
setError(null)
const rows: AppliedRow[] = []
for (const entry of applyList) {
setCurrentPath(entry.path)
try {
const res = await applyWizardImport(
analysis.importId,
entry.path,
decisions[entry.path]?.content,
)
rows.push({ path: entry.path, ok: true, overwritten: res.data.overwritten })
setApplied([...rows])
} catch (err: any) {
const message = err?.response?.data?.error?.message ?? err?.message ?? 'Unknown error'
rows.push({ path: entry.path, ok: false, overwritten: false, message })
setApplied([...rows])
setCurrentPath(null)
setError(message)
try {
await rollbackWizardImport(analysis.importId)
} catch (rollbackErr: any) {
setError(
`${message}${translate('::App.ListForm.WizardImportRollbackFailed')}: ${
rollbackErr?.message ?? 'Unknown error'
}`,
)
}
setPhase('rolledBack')
onImported()
return
}
}
setCurrentPath(null)
try {
await completeWizardImport(analysis.importId)
} catch {
// Oturum klasoru zaten gecici; temizlenemezse import basarili sayilir.
}
setPhase('finished')
onImported()
}
/** Import baslamadan kapatilirsa sunucudaki staging klasoru birakilmaz. */
const handleClose = () => {
if (phase === 'running') return
if (analysis && phase === 'review') {
rollbackWizardImport(analysis.importId).catch(() => undefined)
}
onClose()
}
const progressPercent = applyList.length
? Math.round((applied.length / applyList.length) * 100)
: 0
const renderStatusBadge = (entry: WizardImportEntryDto) => {
const decision = decisions[entry.path]
if (entry.status === WizardImportEntryStatusEnum.Identical)
return (
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-[11px] text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{translate('::App.ListForm.WizardImportIdentical')}
</span>
)
if (entry.status === WizardImportEntryStatusEnum.New)
return (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
{translate('::App.ListForm.WizardImportNew')}
</span>
)
if (!decision?.resolved)
return (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
{translate('::App.ListForm.WizardImportConflict')}
</span>
)
return (
<span
className={classNames(
'rounded-full px-2 py-0.5 text-[11px]',
decision.apply
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
)}
>
{translate(
decision.apply
? '::App.ListForm.WizardImportResolvedApply'
: '::App.ListForm.WizardImportResolvedSkip',
)}
</span>
)
}
const renderReview = () => (
<div className="flex min-h-0 flex-1 gap-3">
{/* Dosya listesi */}
<div className="flex w-72 shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2 text-xs text-gray-500 dark:border-gray-700">
<span className="font-mono truncate">{analysis?.scopeFolder}</span>
<span>
{applyList.length}/{entries.length}
</span>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{visibleEntries.map((entry) => {
const decision = decisions[entry.path]
const isConflict = entry.status === WizardImportEntryStatusEnum.Conflict
return (
<div
key={entry.path}
role="button"
tabIndex={0}
className={classNames(
'flex w-full cursor-pointer items-center gap-2 border-b border-gray-100 px-3 py-2 text-left last:border-b-0 dark:border-gray-800',
selectedPath === entry.path
? 'bg-indigo-50 dark:bg-indigo-900/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-800',
)}
onClick={() => setSelectedPath(entry.path)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') setSelectedPath(entry.path)
}}
>
{isConflict ? (
<FaCodeBranch className="shrink-0 text-amber-500" />
) : entry.status === WizardImportEntryStatusEnum.New ? (
<FaPlusCircle className="shrink-0 text-emerald-500" />
) : (
<FaFileAlt className="shrink-0 text-gray-400" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-gray-800 dark:text-gray-200">
{entry.fileName}
</span>
<span className="block truncate font-mono text-[11px] text-gray-400">
{entry.path}
</span>
</span>
{renderStatusBadge(entry)}
{!isConflict && (
<input
type="checkbox"
className="shrink-0"
checked={!!decision?.apply}
title={translate('::App.ListForm.WizardImportInclude')}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
decide(entry.path, {
apply: e.target.checked,
resolved: true,
})
}
/>
)}
</div>
)
})}
</div>
{identicalCount > 0 && (
<button
type="button"
className="border-t border-gray-200 px-3 py-2 text-left text-[11px] text-gray-500 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
onClick={() => setShowIdentical((v) => !v)}
>
{showIdentical
? translate('::App.ListForm.WizardImportHideIdentical')
: `${identicalCount} ${translate('::App.ListForm.WizardImportShowIdentical')}`}
</button>
)}
</div>
{/* Karsilastirma / detay */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
{selectedEntry && selectedEntry.status === WizardImportEntryStatusEnum.Conflict ? (
<>
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-gray-700">
<FaCodeBranch className="text-amber-500" />
<span className="font-mono text-xs text-gray-700 dark:text-gray-300">
{selectedEntry.path}
</span>
<span className="text-[11px] text-gray-400">
{formatSize(selectedEntry.existingSize)} {formatSize(selectedEntry.incomingSize)}
</span>
<div className="ml-auto flex gap-2">
<Button
size="xs"
variant="plain"
type="button"
icon={<FaUndo />}
onClick={() => handleResetDraft(selectedEntry)}
>
{translate('::App.ListForm.WizardImportResetIncoming')}
</Button>
<Button
size="xs"
variant="default"
type="button"
onClick={() => handleKeepExisting(selectedEntry)}
>
{translate('::App.ListForm.WizardImportKeepExisting')}
</Button>
<Button
size="xs"
variant="solid"
type="button"
onClick={() => handleTakeIncoming(selectedEntry)}
>
{translate('::App.ListForm.WizardImportUseThis')}
</Button>
</div>
</div>
<div className="flex items-center gap-2 border-b border-gray-200 px-3 py-1 text-[11px] text-gray-400 dark:border-gray-700">
<span className="flex-1">{translate('::App.ListForm.WizardImportExistingSide')}</span>
<span className="flex-1">{translate('::App.ListForm.WizardImportIncomingSide')}</span>
</div>
<div className="min-h-0 flex-1">
<DiffEditor
key={`${selectedEntry.path}#${editorEpoch}`}
height="100%"
language={languageOf(selectedEntry.fileName)}
original={selectedEntry.existingContent ?? ''}
modified={
decisions[selectedEntry.path]?.content ?? selectedEntry.incomingContent ?? ''
}
theme={mode === 'dark' ? 'vs-dark' : 'light'}
options={{
renderSideBySide: true,
originalEditable: false,
readOnly: false,
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
onMount={(editor) => {
const modified = editor.getModifiedEditor()
draftRef.current = modified.getValue()
modified.onDidChangeModelContent(() => {
draftRef.current = modified.getValue()
})
}}
/>
</div>
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-gray-400">
<FaFileImport size={32} />
{unresolvedCount > 0 ? (
<span>
{unresolvedCount} {translate('::App.ListForm.WizardImportUnresolved')}
</span>
) : (
<span>{translate('::App.ListForm.WizardImportReady')}</span>
)}
{analysis && analysis.warnings.length > 0 && (
<div className="mt-3 w-full max-w-xl rounded-lg bg-amber-50 p-3 text-left text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
<div className="mb-1 flex items-center gap-1 font-medium">
<FaExclamationTriangle />
{translate('::App.ListForm.WizardImportWarnings')}
</div>
<ul className="list-disc space-y-0.5 pl-4">
{analysis.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
)
const renderProgress = () => (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<Progress
percent={progressPercent}
color={phase === 'rolledBack' ? 'red-500' : undefined}
customInfo={`${applied.length}/${applyList.length}`}
/>
{currentPath && (
<div className="truncate font-mono text-xs text-gray-500">
{translate('::App.ListForm.WizardImportWriting')}: {currentPath}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
{applied.map((row) => (
<div
key={row.path}
className="flex items-center gap-2 border-b border-gray-100 px-3 py-1.5 text-xs last:border-b-0 dark:border-gray-800"
>
{row.ok ? (
<FaCheckCircle className="shrink-0 text-emerald-500" />
) : (
<FaTimesCircle className="shrink-0 text-red-500" />
)}
<span className="min-w-0 flex-1 truncate font-mono text-gray-700 dark:text-gray-300">
{row.path}
</span>
<span className="shrink-0 text-[11px] text-gray-400">
{row.ok
? translate(
row.overwritten
? '::App.ListForm.WizardImportOverwritten'
: '::App.ListForm.WizardImportCreated',
)
: row.message}
</span>
</div>
))}
</div>
{phase === 'rolledBack' && (
<div className="rounded-lg bg-red-50 p-3 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300">
<div className="flex items-center gap-1 font-medium">
<FaExclamationTriangle />
{translate('::App.ListForm.WizardImportRolledBack')}
</div>
{error && <div className="mt-1 break-all">{error}</div>}
</div>
)}
{phase === 'finished' && (
<div className="rounded-lg bg-emerald-50 p-3 text-xs text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300">
<div className="flex items-center gap-1 font-medium">
<FaCheckCircle />
{translate('::App.ListForm.WizardImportSuccess')}
</div>
<div className="mt-1">{translate('::App.ListForm.WizardImportRunMigrateHint')}</div>
</div>
)}
</div>
)
return (
<Dialog
preventScroll
isOpen={file !== null}
width={1360}
contentClassName="flex flex-col"
closable={phase !== 'running'}
onClose={handleClose}
onRequestClose={handleClose}
>
<Dialog.Body className="mt-5 flex h-[calc(100vh-14rem)] max-h-[calc(90vh-7rem)] min-h-[360px] !flex-none flex-col gap-3 !overflow-hidden">
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
<FaFileImport className="text-sky-500" />
<h5 className="mr-1">{translate('::App.ListForm.WizardImportTitle')}</h5>
<span className="truncate font-mono text-xs text-gray-400">{file?.name}</span>
{phase === 'review' && analysis && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-[11px]">
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
{entries.filter((e) => e.status === WizardImportEntryStatusEnum.New).length}{' '}
{translate('::App.ListForm.WizardImportNew')}
</span>
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
{conflicts.length} {translate('::App.ListForm.WizardImportConflict')}
</span>
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{entries.filter((e) => e.status === WizardImportEntryStatusEnum.Identical).length}{' '}
{translate('::App.ListForm.WizardImportIdentical')}
</span>
</div>
)}
</div>
{phase === 'analyzing' && (
<div className="flex flex-1 items-center justify-center text-sm text-gray-400">
<span className="animate-pulse">
{translate('::App.ListForm.WizardImportAnalyzing')}
</span>
</div>
)}
{phase === 'review' && error && (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-red-500">
<FaExclamationTriangle size={28} />
<span className="break-all text-center">{error}</span>
</div>
)}
{phase === 'review' && !error && analysis && renderReview()}
{(phase === 'running' || phase === 'finished' || phase === 'rolledBack') &&
renderProgress()}
</Dialog.Body>
<Dialog.Footer className="mt-1 flex items-center justify-end gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
{phase === 'review' && !error && unresolvedCount > 0 && (
<span className="mr-auto text-xs text-amber-600 dark:text-amber-400">
{unresolvedCount} {translate('::App.ListForm.WizardImportUnresolved')}
</span>
)}
<Button
size="sm"
type="button"
variant="plain"
icon={<FaTimes />}
disabled={phase === 'running'}
onClick={handleClose}
>
{translate(
phase === 'finished' || phase === 'rolledBack'
? '::App.Platform.Close'
: '::App.Platform.Cancel',
)}
</Button>
{phase === 'review' && !error && (
<Button
size="sm"
type="button"
variant="solid"
icon={<FaCheck />}
disabled={unresolvedCount > 0 || applyList.length === 0}
onClick={handleStart}
>
{translate('::App.ListForm.WizardImportStart')} ({applyList.length})
</Button>
)}
</Dialog.Footer>
</Dialog>
)
}
export default WizardImportDialog

View file

@ -73,6 +73,7 @@ const WizardStepCustomComponent = ({
<Select <Select
field={field} field={field}
form={form} form={form}
size="md"
isClearable={true} isClearable={true}
options={componentOptions} options={componentOptions}
placeholder={translate('::App.WizardStepComponent.SelectComponent')} placeholder={translate('::App.WizardStepComponent.SelectComponent')}

View file

@ -21,7 +21,7 @@ import { usePermission } from '@/utils/hooks/usePermission'
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
export interface WizardStep2Props { export interface WizardStepDataSettingsProps {
values: ListFormWizardDto values: ListFormWizardDto
errors: FormikErrors<ListFormWizardDto> errors: FormikErrors<ListFormWizardDto>
touched: FormikTouched<ListFormWizardDto> touched: FormikTouched<ListFormWizardDto>
@ -50,9 +50,14 @@ export interface WizardStep2Props {
onNext: () => void onNext: () => void
} }
// ─── WizardStep2 ────────────────────────────────────────────────────────────── // ─── WizardStepDataSettings ──────────────────────────────────────────────────────────────
const WizardStep2 = ({ /**
* Veri ayarları adımı: ListForm kodu, veri kaynağı, select komutu (tablo/view/sp/query) ve
* listenin izin verdiği işlemler (ekleme/güncelleme/silme/detay) ile layout seçenekleri.
* Sonraki alan adımının okuduğu kolon listesi buradaki select komutundan çözülür.
*/
const WizardStepDataSettings = ({
values, values,
errors, errors,
touched, touched,
@ -75,7 +80,7 @@ const WizardStep2 = ({
translate, translate,
onBack, onBack,
onNext, onNext,
}: WizardStep2Props) => { }: WizardStepDataSettingsProps) => {
const [showTableDesignerDialog, setShowTableDesignerDialog] = useState(false) const [showTableDesignerDialog, setShowTableDesignerDialog] = useState(false)
const [designTableData, setDesignTableData] = useState<{ const [designTableData, setDesignTableData] = useState<{
schemaName: string schemaName: string
@ -826,4 +831,4 @@ const WizardStep2 = ({
) )
} }
export default WizardStep2 export default WizardStepDataSettings

View file

@ -11,7 +11,7 @@ import {
FaRocket, FaRocket,
FaSpinner, FaSpinner,
} from 'react-icons/fa' } from 'react-icons/fa'
import { WizardGroup } from './WizardStep3' import { WizardGroup } from './WizardStepFields'
import { dbSourceTypeOptions, selectCommandTypeOptions } from '../edit/options' import { dbSourceTypeOptions, selectCommandTypeOptions } from '../edit/options'
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models' import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
import { import {
@ -24,7 +24,7 @@ import { formatWorkflowApprovers } from '@/utils/workflow/workflowHelpers'
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
export interface WizardStep11Props { export interface WizardStepDeployProps {
values: ListFormWizardDto values: ListFormWizardDto
wizardName: string wizardName: string
selectedColumns: Set<string> selectedColumns: Set<string>
@ -201,8 +201,13 @@ function Row({ label, value }: { label: string; value?: string | number }) {
) )
} }
// ─── WizardStep11 ───────────────────────────────────────────────────────────── // ─── WizardStepDeploy ─────────────────────────────────────────────────────────────
const WizardStep11 = ({ /**
* Deploy adımı: önceki adımlarda toplanan her şeyin özetini gösterir ve onay sonrası
* wizard'ı sunucuya gönderir. Sunucu bu istekten menü, izin, dil, ListForm ve seed
* dosyasını üretir; özet ekranında bağlantı cümlesi maskelenerek gösterilir.
*/
const WizardStepDeploy = ({
values, values,
wizardName, wizardName,
selectedColumns, selectedColumns,
@ -215,7 +220,7 @@ const WizardStep11 = ({
translate, translate,
onBack, onBack,
onSubmit, onSubmit,
}: WizardStep11Props) => { }: WizardStepDeployProps) => {
const [logs, setLogs] = useState<LogEntry[]>([]) const [logs, setLogs] = useState<LogEntry[]>([])
const [isDeploying, setIsDeploying] = useState(false) const [isDeploying, setIsDeploying] = useState(false)
const [isDone, setIsDone] = useState(false) const [isDone, setIsDone] = useState(false)
@ -829,4 +834,4 @@ const WizardStep11 = ({
) )
} }
export default WizardStep11 export default WizardStepDeploy

View file

@ -62,7 +62,7 @@ export interface WizardGroup {
items: WizardGroupItem[] items: WizardGroupItem[]
} }
export interface WizardStep3Props { export interface WizardStepFieldsProps {
selectedColumns: Set<string> selectedColumns: Set<string>
selectCommandColumns: DatabaseColumnDto[] selectCommandColumns: DatabaseColumnDto[]
groups: WizardGroup[] groups: WizardGroup[]
@ -641,9 +641,7 @@ function SortableItem({
{/* Bottom row: ColSpan + Editing Form + Required */} {/* Bottom row: ColSpan + Editing Form + Required */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[10px] text-gray-400"> <span className="text-[10px] text-gray-400">{translate('::App.WizardStep3.Span')}</span>
{translate('::App.WizardStep3.Span')}
</span>
<select <select
value={item.colSpan} value={item.colSpan}
onChange={(e) => onColSpanChange(Number(e.target.value))} onChange={(e) => onColSpanChange(Number(e.target.value))}
@ -739,9 +737,7 @@ function GroupCard({
/> />
{/* ColCount */} {/* ColCount */}
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">{translate('::App.WizardStep3.Cols')}</span>
{translate('::App.WizardStep3.Cols')}
</span>
{[1, 2, 3].map((n) => ( {[1, 2, 3].map((n) => (
<Button <Button
key={n} key={n}
@ -839,8 +835,13 @@ function GroupCard({
) )
} }
// ─── WizardStep3 ────────────────────────────────────────────────────────────── // ─── WizardStepFields ──────────────────────────────────────────────────────────────
const WizardStep3 = ({ /**
* Alan adımı: select komutundan gelen kolonların ListForm alanlarına dönüştürüldüğü yer.
* Kolon grupları, editör tipi/seçenekleri, zorunluluk, lookup ve TR/EN başlıklar burada
* düzenlenir; üretilen gruplar deploy'da ListFormField kayıtlarına çevrilir.
*/
const WizardStepFields = ({
selectedColumns, selectedColumns,
selectCommandColumns, selectCommandColumns,
groups, groups,
@ -850,7 +851,7 @@ const WizardStep3 = ({
translate, translate,
onBack, onBack,
onNext, onNext,
}: WizardStep3Props) => { }: WizardStepFieldsProps) => {
const [activeId, setActiveId] = useState<string | null>(null) const [activeId, setActiveId] = useState<string | null>(null)
const [overGroupId, setOverGroupId] = useState<string | null>(null) const [overGroupId, setOverGroupId] = useState<string | null>(null)
const [isHelperOpen, setIsHelperOpen] = useState(false) const [isHelperOpen, setIsHelperOpen] = useState(false)
@ -1266,4 +1267,4 @@ const WizardStep3 = ({
) )
} }
export default WizardStep3 export default WizardStepFields

View file

@ -7,14 +7,24 @@ import { useMemo } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa' import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
import { gantScaleOptions } from '../edit/options' import { gantScaleOptions } from '../edit/options'
interface WizardStep9Props { interface WizardStepGanttLayoutProps {
selectCommandColumns: DatabaseColumnDto[] selectCommandColumns: DatabaseColumnDto[]
translate: (key: string) => string translate: (key: string) => string
onBack: () => void onBack: () => void
onNext: () => void onNext: () => void
} }
const WizardStep9 = ({ selectCommandColumns, translate, onBack, onNext }: WizardStep9Props) => { /**
* Gantt layout adımı: görev başlığı, başlangıç/bitiş tarihi, ilerleme ve hiyerarşi
* (anahtar / üst anahtar) kolonlarının eşleştirilmesi ile ölçek ve genişletme davranışı.
* Yalnızca Gantt layout'u seçiliyken gösterilir.
*/
const WizardStepGanttLayout = ({
selectCommandColumns,
translate,
onBack,
onNext,
}: WizardStepGanttLayoutProps) => {
const { values } = useFormikContext<ListFormWizardDto>() const { values } = useFormikContext<ListFormWizardDto>()
const fieldOptions = useMemo<SelectBoxOption[]>( const fieldOptions = useMemo<SelectBoxOption[]>(
() => () =>
@ -125,4 +135,4 @@ const WizardStep9 = ({ selectCommandColumns, translate, onBack, onNext }: Wizard
) )
} }
export default WizardStep9 export default WizardStepGanttLayout

View file

@ -352,9 +352,9 @@ function MenuTreeInline({
) )
} }
// ─── WizardStep1 ────────────────────────────────────────────────────────────── // ─── WizardStepMenu ──────────────────────────────────────────────────────────────
export interface WizardStep1Props { export interface WizardStepMenuProps {
values: ListFormWizardDto values: ListFormWizardDto
errors: FormikErrors<ListFormWizardDto> errors: FormikErrors<ListFormWizardDto>
touched: FormikTouched<ListFormWizardDto> touched: FormikTouched<ListFormWizardDto>
@ -383,7 +383,12 @@ export interface WizardStep1Props {
translate: (key: string) => string translate: (key: string) => string
} }
const WizardStep1 = ({ /**
* Menü adımı: wizard adı, menü kodu/sırası, üst menü, ikon ve izin grubu burada belirlenir.
* Menü ağacını kurup ad/kod çakışmalarını adım geçilmeden önce doğrular; ürettiği menü
* kaydı hem List hem Custom yolunda deploy'un çıktısıdır.
*/
const WizardStepMenu = ({
values, values,
errors, errors,
touched, touched,
@ -402,7 +407,7 @@ const WizardStep1 = ({
onBack, onBack,
onNext, onNext,
translate, translate,
}: WizardStep1Props) => { }: WizardStepMenuProps) => {
const [menuDialogOpen, setMenuDialogOpen] = useState(false) const [menuDialogOpen, setMenuDialogOpen] = useState(false)
const [menuDialogParentCode, setMenuDialogParentCode] = useState('') const [menuDialogParentCode, setMenuDialogParentCode] = useState('')
// Dolu ise dialog düzenleme modunda açılır. // Dolu ise dialog düzenleme modunda açılır.
@ -745,4 +750,4 @@ const WizardStep1 = ({
) )
} }
export default WizardStep1 export default WizardStepMenu

View file

@ -7,14 +7,23 @@ import { useMemo } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa' import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
import { firstDayOfWeekOptions, schedulerViewOptions } from '../edit/options' import { firstDayOfWeekOptions, schedulerViewOptions } from '../edit/options'
interface WizardStep10Props { interface WizardStepSchedulerLayoutProps {
selectCommandColumns: DatabaseColumnDto[] selectCommandColumns: DatabaseColumnDto[]
translate: (key: string) => string translate: (key: string) => string
onBack: () => void onBack: () => void
onNext: () => void onNext: () => void
} }
const WizardStep10 = ({ selectCommandColumns, translate, onBack, onNext }: WizardStep10Props) => { /**
* Takvim layout adımı: randevu başlığı, başlangıç/bitiş ve tüm gün gibi Scheduler
* görünümünün kolon eşleşmeleri. Yalnızca Scheduler layout'u seçiliyken gösterilir.
*/
const WizardStepSchedulerLayout = ({
selectCommandColumns,
translate,
onBack,
onNext,
}: WizardStepSchedulerLayoutProps) => {
const { values } = useFormikContext<ListFormWizardDto>() const { values } = useFormikContext<ListFormWizardDto>()
const fieldOptions = useMemo<SelectBoxOption[]>( const fieldOptions = useMemo<SelectBoxOption[]>(
() => () =>
@ -160,4 +169,4 @@ const WizardStep10 = ({ selectCommandColumns, translate, onBack, onNext }: Wizar
) )
} }
export default WizardStep10 export default WizardStepSchedulerLayout

View file

@ -51,7 +51,12 @@ const createEmptySubForm = (): SubFormDto => ({
searchParams: new URLSearchParams(), searchParams: new URLSearchParams(),
}) })
function WizardStep4({ /**
* Alt form adımı: ana kayda bağlı sekmelerde ılacak ListForm'ların (master-detail)
* tanımlandığı yer. Her satır bir sekme başlığı, bağlanacak ListForm kodu ve yenileme
* davranışı taşır.
*/
function WizardStepSubForms({
subForms, subForms,
selectCommandColumns, selectCommandColumns,
translate, translate,
@ -481,4 +486,4 @@ function WizardStep4({
) )
} }
export default WizardStep4 export default WizardStepSubForms

View file

@ -7,7 +7,7 @@ import { Field, type FieldProps, useFormikContext } from 'formik'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa' import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
interface WizardStep7Props { interface WizardStepTodoLayoutProps {
selectedColumns: Set<string> selectedColumns: Set<string>
selectCommandColumns: DatabaseColumnDto[] selectCommandColumns: DatabaseColumnDto[]
translate: (key: string) => string translate: (key: string) => string
@ -54,13 +54,17 @@ const mappings: Array<{
{ name: 'orderExpr', translationKey: '::App.ListFormFieldEdit.TodoOrderExpr' }, { name: 'orderExpr', translationKey: '::App.ListFormFieldEdit.TodoOrderExpr' },
] ]
const WizardStep7 = ({ /**
* Todo / Kanban layout adımı: kart başlığı, durum kolonu, sıra alanı gibi Kanban görünümünün
* ihtiyaç duyduğu kolon eşleşmeleri. Yalnızca Todo layout'u seçiliyken gösterilir.
*/
const WizardStepTodoLayout = ({
selectedColumns, selectedColumns,
selectCommandColumns, selectCommandColumns,
translate, translate,
onBack, onBack,
onNext, onNext,
}: WizardStep7Props) => { }: WizardStepTodoLayoutProps) => {
const { values, setFieldValue } = useFormikContext<ListFormWizardDto>() const { values, setFieldValue } = useFormikContext<ListFormWizardDto>()
const [validationError, setValidationError] = useState('') const [validationError, setValidationError] = useState('')
const fieldOptions = useMemo<SelectBoxOption[]>( const fieldOptions = useMemo<SelectBoxOption[]>(
@ -160,4 +164,4 @@ const WizardStep7 = ({
) )
} }
export default WizardStep7 export default WizardStepTodoLayout

View file

@ -6,14 +6,23 @@ import { Field, type FieldProps, useFormikContext } from 'formik'
import { useMemo } from 'react' import { useMemo } from 'react'
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa' import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
interface WizardStep8Props { interface WizardStepTreeLayoutProps {
selectCommandColumns: DatabaseColumnDto[] selectCommandColumns: DatabaseColumnDto[]
translate: (key: string) => string translate: (key: string) => string
onBack: () => void onBack: () => void
onNext: () => void onNext: () => void
} }
const WizardStep8 = ({ selectCommandColumns, translate, onBack, onNext }: WizardStep8Props) => { /**
* Ağaç layout adımı: kayıtları hiyerarşik gösterebilmek için anahtar ve üst anahtar
* kolonlarının eşleştirilmesi. Yalnızca Tree layout'u seçiliyken gösterilir.
*/
const WizardStepTreeLayout = ({
selectCommandColumns,
translate,
onBack,
onNext,
}: WizardStepTreeLayoutProps) => {
const { values } = useFormikContext<ListFormWizardDto>() const { values } = useFormikContext<ListFormWizardDto>()
const fieldOptions = useMemo<SelectBoxOption[]>( const fieldOptions = useMemo<SelectBoxOption[]>(
() => () =>
@ -85,4 +94,4 @@ const WizardStep8 = ({ selectCommandColumns, translate, onBack, onNext }: Wizard
) )
} }
export default WizardStep8 export default WizardStepTreeLayout

View file

@ -54,7 +54,11 @@ const emptyWidget: WidgetEditDto = {
isActive: true, isActive: true,
} }
function WizardStep5({ widgets, translate, onChange, onBack, onNext }: Props) { /**
* Widget adımı: listenin üstünde gösterilecek özet kartlarının (SQL sorgusu, kolon genişliği,
* stil sınıfı) tanımlandığı yer.
*/
function WizardStepWidgets({ widgets, translate, onChange, onBack, onNext }: Props) {
const [dialogIndex, setDialogIndex] = useState<number | null>(null) const [dialogIndex, setDialogIndex] = useState<number | null>(null)
const [deleteIndex, setDeleteIndex] = useState<number | null>(null) const [deleteIndex, setDeleteIndex] = useState<number | null>(null)
@ -352,4 +356,4 @@ function WizardStep5({ widgets, translate, onChange, onBack, onNext }: Props) {
) )
} }
export default WizardStep5 export default WizardStepWidgets

View file

@ -49,7 +49,11 @@ const toDesignerCriteria = (items: ListFormWorkflowCriteriaDto[]): WorkflowCrite
const toWizardCriteria = (items: WorkflowCriteriaDto[]): ListFormWorkflowCriteriaDto[] => const toWizardCriteria = (items: WorkflowCriteriaDto[]): ListFormWorkflowCriteriaDto[] =>
items.map(({ ...item }) => item) items.map(({ ...item }) => item)
function WizardStep6({ /**
* Onay akışı adımı: kaydın hangi durumlarda hangi role/kullanıcıya düşeceğini belirleyen
* workflow düğümleri ve kriterleri burada kurgulanır.
*/
function WizardStepWorkflow({
listFormCode, listFormCode,
workflow, workflow,
criteria, criteria,
@ -302,18 +306,12 @@ function WizardStep6({
<FormContainer> <FormContainer>
<div className="grid grid-cols-1 md:grid-cols-6 gap-4"> <div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{[ {[
[ ['approvalUserFieldName', '::App.ListFormEditWorkflow.ApprovalUserFieldName'],
'approvalUserFieldName',
'::App.ListFormEditWorkflow.ApprovalUserFieldName',
],
[ [
'approvalStatusFieldName', 'approvalStatusFieldName',
'::App.ListFormEditWorkflow.ApprovalStatusFieldName', '::App.ListFormEditWorkflow.ApprovalStatusFieldName',
], ],
[ ['approvalDateFieldName', '::App.ListFormEditWorkflow.ApprovalDateFieldName'],
'approvalDateFieldName',
'::App.ListFormEditWorkflow.ApprovalDateFieldName',
],
[ [
'approvalDescriptionFieldName', 'approvalDescriptionFieldName',
'::App.ListFormEditWorkflow.ApprovalDescriptionFieldName', '::App.ListFormEditWorkflow.ApprovalDescriptionFieldName',
@ -360,9 +358,7 @@ function WizardStep6({
) )
})} })}
<FormItem <FormItem
label={translate( label={translate('::App.ListFormEditWorkflow.ApprovalIsFilterUserName')}
'::App.ListFormEditWorkflow.ApprovalIsFilterUserName',
)}
> >
<Field name="approvalIsFilterUserName"> <Field name="approvalIsFilterUserName">
{({ field, form }: FieldProps<boolean>) => ( {({ field, form }: FieldProps<boolean>) => (
@ -489,4 +485,4 @@ function WizardStep6({
) )
} }
export default WizardStep6 export default WizardStepWorkflow

View file

@ -5,6 +5,8 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
import { Formik, Form, Field } from 'formik' import { Formik, Form, Field } from 'formik'
import * as Yup from 'yup' import * as Yup from 'yup'
import { Button, Checkbox, FormContainer, FormItem, Input } from '@/components/ui' import { Button, Checkbox, FormContainer, FormItem, Input } from '@/components/ui'
import { usePermission } from '@/utils/hooks/usePermission'
import { COMPONENT_PERMISSION } from '@/constants/permission.constant'
import Dialog from '@/components/ui/Dialog' import Dialog from '@/components/ui/Dialog'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator' import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import { import {
@ -42,6 +44,12 @@ const ComponentEditor: React.FC<ComponentEditorProps> = ({
const isEditing = !!id const isEditing = !!id
// Kaydetme hakki moda gore degisir: mevcut kayit guncelleme, yeni kayit olusturma yetkisi ister.
const { checkPermission } = usePermission()
const canSave = checkPermission(
isEditing ? COMPONENT_PERMISSION.UPDATE : COMPONENT_PERMISSION.CREATE,
)
// Initial values for Formik // Initial values for Formik
const [initialValues, setInitialValues] = useState({ const [initialValues, setInitialValues] = useState({
name: '', name: '',
@ -270,7 +278,7 @@ const ComponentEditor: React.FC<ComponentEditorProps> = ({
type="button" type="button"
variant="solid" variant="solid"
size="sm" size="sm"
disabled={isSubmitting || !values.name.trim() || !isValid} disabled={!canSave || isSubmitting || !values.name.trim() || !isValid}
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={submitForm} onClick={submitForm}
> >

View file

@ -22,6 +22,8 @@ import { Button } from '@/components/ui'
import Dialog from '@/components/ui/Dialog' import Dialog from '@/components/ui/Dialog'
import ComponentEditor from './ComponentEditor' import ComponentEditor from './ComponentEditor'
import { parseComponentDependencies } from '@/contexts/componentRuntime' import { parseComponentDependencies } from '@/contexts/componentRuntime'
import { usePermission } from '@/utils/hooks/usePermission'
import { COMPONENT_PERMISSION } from '@/constants/permission.constant'
const ComponentManager: React.FC = () => { const ComponentManager: React.FC = () => {
const { const {
@ -42,6 +44,10 @@ const ComponentManager: React.FC = () => {
const activeComponents = components?.filter((c) => c.isActive).length || 0 const activeComponents = components?.filter((c) => c.isActive).length || 0
const inactiveComponents = totalComponents - activeComponents const inactiveComponents = totalComponents - activeComponents
const { translate } = useLocalization() const { translate } = useLocalization()
const { checkPermission } = usePermission()
const canCreate = checkPermission(COMPONENT_PERMISSION.CREATE)
const canUpdate = checkPermission(COMPONENT_PERMISSION.UPDATE)
const canDelete = checkPermission(COMPONENT_PERMISSION.DELETE)
useEffect(() => { useEffect(() => {
void refreshComponents() void refreshComponents()
@ -159,17 +165,19 @@ const ComponentManager: React.FC = () => {
<FaSyncAlt className={isRefreshing ? 'animate-spin' : ''} /> <FaSyncAlt className={isRefreshing ? 'animate-spin' : ''} />
{translate('::App.Platform.Refresh')} {translate('::App.Platform.Refresh')}
</Button> </Button>
<div> {canCreate && (
<Button <div>
type="button" <Button
variant="solid" type="button"
className="flex items-center gap-2" variant="solid"
onClick={() => setEditorComponentId(null)} className="flex items-center gap-2"
> onClick={() => setEditorComponentId(null)}
<FaPlus className="w-4 h-4" /> >
{translate('::App.DeveloperKitComponent.New')} <FaPlus className="w-4 h-4" />
</Button> {translate('::App.DeveloperKitComponent.New')}
</div> </Button>
</div>
)}
</div> </div>
{/* Components List */} {/* Components List */}
@ -244,6 +252,7 @@ const ComponentManager: React.FC = () => {
type="button" type="button"
variant="plain" variant="plain"
shape="circle" shape="circle"
disabled={!canUpdate}
className={`!inline-flex !h-auto items-center gap-1 rounded !px-2 py-1 text-xs font-medium transition-colors`} className={`!inline-flex !h-auto items-center gap-1 rounded !px-2 py-1 text-xs font-medium transition-colors`}
onClick={() => handleToggleActive(component.id, !component.isActive)} onClick={() => handleToggleActive(component.id, !component.isActive)}
> >
@ -261,23 +270,25 @@ const ComponentManager: React.FC = () => {
</Button> </Button>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button {canUpdate && (
type="button" <Button
variant="solid" type="button"
color="blue-600" variant="solid"
title={translate('::App.Platform.Edit')} color="blue-600"
onClick={() => title={translate('::App.Platform.Edit')}
window.open( onClick={() =>
ROUTES_ENUM.protected.saas.developerKit.componentsEdit.replace( window.open(
':id', ROUTES_ENUM.protected.saas.developerKit.componentsEdit.replace(
component.id, ':id',
), component.id,
'_blank', ),
) '_blank',
} )
> }
<FaRegEdit className="w-4 h-4" /> >
</Button> <FaRegEdit className="w-4 h-4" />
</Button>
)}
<Button <Button
type="button" type="button"
variant="solid" variant="solid"
@ -302,15 +313,17 @@ const ComponentManager: React.FC = () => {
> >
<FaCog className="w-4 h-4" /> <FaCog className="w-4 h-4" />
</Button> </Button>
<Button {canDelete && (
type="button" <Button
variant="solid" type="button"
color="red-600" variant="solid"
title={translate('::App.Platform.Delete')} color="red-600"
onClick={() => handleDelete(component.id)} title={translate('::App.Platform.Delete')}
> onClick={() => handleDelete(component.id)}
<FaTrashAlt className="w-4 h-4" /> >
</Button> <FaTrashAlt className="w-4 h-4" />
</Button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -333,7 +346,7 @@ const ComponentManager: React.FC = () => {
? translate('::App.DeveloperKit.EmptyFilteredDescription') ? translate('::App.DeveloperKit.EmptyFilteredDescription')
: translate('::App.EmptyInitial.InitialDescription')} : translate('::App.EmptyInitial.InitialDescription')}
</p> </p>
{!searchTerm && filterActive === 'all' && ( {canCreate && !searchTerm && filterActive === 'all' && (
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors" className="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"

View file

@ -27,7 +27,7 @@ import {
MenuTreeNode, MenuTreeNode,
buildMenuTree, buildMenuTree,
filterNonLinkNodes, filterNonLinkNodes,
} from '@/views/admin/listForm/wizard/WizardStep1' } from '@/views/admin/listForm/wizard/WizardStepMenu'
import { MenuAddDialog } from '../shared/MenuAddDialog' import { MenuAddDialog } from '../shared/MenuAddDialog'
import { useStoreActions, useStoreState } from '@/store' import { useStoreActions, useStoreState } from '@/store'
import { usePermission } from '@/utils/hooks/usePermission' import { usePermission } from '@/utils/hooks/usePermission'