broke out code

This commit is contained in:
Chris Rosenau 2025-11-12 17:52:54 -07:00
parent 54ffc41d46
commit 99474ccd75
28 changed files with 5358 additions and 2776 deletions

View File

@ -0,0 +1,6 @@
---
alwaysApply: true
---
use tailwind standards for css
use blade partials for reusable components
tabs should be specific pages with specific controllers

View File

@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers;
use App\Services\MagentoCategoryMigrationService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class AttributesController extends Controller
{
protected $migrationService;
public function __construct(MagentoCategoryMigrationService $migrationService)
{
$this->migrationService = $migrationService;
}
/**
* Show the attributes page
*/
public function index()
{
$m1Attributes = $this->migrationService->getMagento1Attributes();
$m2Attributes = $this->migrationService->getMagento2Attributes();
$m1AttributesMissingInM2 = $this->migrationService->getM1AttributesMissingInM2();
$m1AttributeGroups = $this->migrationService->getMagento1AttributeGroups();
$m2AttributeGroups = $this->migrationService->getMagento2AttributeGroups();
$m1AttributeGroupsMissingInM2 = $this->migrationService->getM1AttributeGroupsMissingInM2();
return view('attributes.index', [
'm1Attributes' => $m1Attributes,
'm2Attributes' => $m2Attributes,
'm1AttributesMissingInM2' => $m1AttributesMissingInM2,
'm1AttributeGroups' => $m1AttributeGroups,
'm2AttributeGroups' => $m2AttributeGroups,
'm1AttributeGroupsMissingInM2' => $m1AttributeGroupsMissingInM2,
]);
}
/**
* Migrate an attribute from Magento 1 to Magento 2
*/
public function migrateAttribute(Request $request, $attributeId)
{
try {
$result = $this->migrationService->migrateAttribute($attributeId);
return response()->json($result, $result['success'] ? 200 : 400);
} catch (\Exception $e) {
Log::error('Attribute migration error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Migration failed: ' . $e->getMessage(),
], 500);
}
}
/**
* Migrate an attribute group from Magento 1 to Magento 2
*/
public function migrateAttributeGroup(Request $request, $groupId, $setId)
{
try {
$result = $this->migrationService->migrateAttributeGroup($groupId, $setId);
return response()->json($result, $result['success'] ? 200 : 400);
} catch (\Exception $e) {
Log::error('Attribute group migration error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Migration failed: ' . $e->getMessage(),
], 500);
}
}
}

View File

@ -0,0 +1,228 @@
<?php
namespace App\Http\Controllers;
use App\Services\MagentoCategoryMigrationService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class CategoriesController extends Controller
{
protected $migrationService;
public function __construct(MagentoCategoryMigrationService $migrationService)
{
$this->migrationService = $migrationService;
}
/**
* Show the categories page
*/
public function index()
{
$m1Categories = $this->migrationService->getMagento1Categories();
$m2Categories = $this->migrationService->getMagento2Categories();
$m2CategoriesNotInM1 = $this->migrationService->getM2CategoriesNotInM1();
return view('categories.index', [
'm1CategoriesCount' => $m1Categories->count(),
'm2CategoriesCount' => $m2Categories->count(),
'm2CategoriesNotInM1' => $m2CategoriesNotInM1,
]);
}
/**
* Get Magento 1 categories preview
*/
public function getMagento1Categories()
{
$categories = $this->migrationService->getMagento1Categories();
return response()->json([
'success' => true,
'count' => $categories->count(),
'categories' => $categories->take(50)->map(function($cat) {
return [
'id' => $cat->entity_id,
'name' => $cat->name ?? 'N/A',
'level' => $cat->level,
'parent_id' => $cat->parent_id,
'is_active' => $cat->is_active ?? 0,
];
}),
]);
}
/**
* Get Magento 1 category tree
*/
public function getMagento1CategoryTree()
{
try {
$categories = $this->migrationService->getMagento1Categories();
$tree = $this->migrationService->buildCategoryTreeHierarchy($categories, 'm1');
return response()->json([
'success' => true,
'tree' => $tree,
]);
} catch (\Exception $e) {
Log::error('Error fetching M1 category tree: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Failed to fetch category tree: ' . $e->getMessage(),
], 500);
}
}
/**
* Get Magento 1 category tree with products
*/
public function getMagento1CategoryTreeWithProducts()
{
try {
$tree = $this->migrationService->getMagento1CategoryTreeWithProducts();
return response()->json([
'success' => true,
'tree' => $tree
]);
} catch (\Exception $e) {
Log::error('Error fetching M1 category tree with products: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Failed to load category tree with products: ' . $e->getMessage(),
'tree' => []
], 500);
}
}
/**
* Get Magento 2 category tree
*/
public function getMagento2CategoryTree()
{
try {
$categories = $this->migrationService->getMagento2Categories();
$tree = $this->migrationService->buildCategoryTreeHierarchy($categories, 'm2');
return response()->json([
'success' => true,
'tree' => $tree,
]);
} catch (\Exception $e) {
Log::error('Error fetching M2 category tree: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Failed to fetch category tree: ' . $e->getMessage(),
], 500);
}
}
/**
* Get Magento 2 category tree with products
*/
public function getMagento2CategoryTreeWithProducts()
{
try {
$tree = $this->migrationService->getMagento2CategoryTreeWithProducts();
return response()->json([
'success' => true,
'tree' => $tree
]);
} catch (\Exception $e) {
Log::error('Error fetching M2 category tree with products: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Failed to load category tree with products: ' . $e->getMessage(),
'tree' => []
], 500);
}
}
/**
* Get M2 categories not in M1 (for AJAX refresh)
*/
public function getM2CategoriesNotInM1()
{
try {
$categories = $this->migrationService->getM2CategoriesNotInM1();
return response()->json([
'success' => true,
'categories' => $categories->map(function($category) {
return [
'entity_id' => $category->entity_id,
'name' => $category->name ?? 'Unnamed Category',
'level' => $category->level ?? 'N/A',
'is_active' => $category->is_active ?? 0,
'root_category_name' => $category->root_category_name ?? 'N/A',
'root_category_id' => $category->root_category_id ?? null,
'path' => $category->path ?? 'N/A',
];
}),
'count' => $categories->count(),
]);
} catch (\Exception $e) {
Log::error('Error fetching M2 categories not in M1: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Failed to fetch categories: ' . $e->getMessage(),
], 500);
}
}
/**
* Delete a category
*/
public function deleteCategory(Request $request, $categoryId)
{
$request->validate([
'source' => 'nullable|in:m1,m2',
]);
try {
$source = $request->input('source', 'm2');
$result = $this->migrationService->deleteCategory($categoryId, $source);
return response()->json($result, $result['success'] ? 200 : 400);
} catch (\Exception $e) {
Log::error('Category deletion error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Deletion failed: ' . $e->getMessage(),
], 500);
}
}
/**
* Rename a Magento 2 category
*/
public function renameCategory(Request $request, $categoryId)
{
$request->validate([
'name' => 'required|string|max:255',
'store_id' => 'nullable|integer',
]);
try {
$newName = $request->input('name');
$storeId = $request->input('store_id', 0);
$result = $this->migrationService->renameCategory($categoryId, $newName, $storeId);
return response()->json($result, $result['success'] ? 200 : 400);
} catch (\Exception $e) {
Log::error('Category rename error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Rename failed: ' . $e->getMessage(),
], 500);
}
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Services\MagentoCategoryMigrationService;
use Illuminate\Http\Request;
class ConnectionsController extends Controller
{
protected $migrationService;
public function __construct(MagentoCategoryMigrationService $migrationService)
{
$this->migrationService = $migrationService;
}
/**
* Show the database connections page
*/
public function index()
{
$connectionTest = $this->migrationService->testConnections();
return view('connections.index', [
'connectionTest' => $connectionTest,
]);
}
/**
* Test database connections
*/
public function testConnections()
{
$results = $this->migrationService->testConnections();
return response()->json([
'success' => $results['magento1'] && $results['magento2'],
'results' => $results,
]);
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Services\MagentoCategoryMigrationService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class MigrationController extends Controller
{
protected $migrationService;
public function __construct(MagentoCategoryMigrationService $migrationService)
{
$this->migrationService = $migrationService;
}
/**
* Show the category migration page
*/
public function index()
{
$m1Stores = $this->migrationService->getMagento1Stores();
$m2Stores = $this->migrationService->getMagento2Stores();
$connectionTest = $this->migrationService->testConnections();
$m1Categories = $this->migrationService->getMagento1Categories();
$m2Categories = $this->migrationService->getMagento2Categories();
return view('migration.index', [
'm1Stores' => $m1Stores,
'm2Stores' => $m2Stores,
'connectionTest' => $connectionTest,
'm1CategoriesCount' => $m1Categories->count(),
'm2CategoriesCount' => $m2Categories->count(),
]);
}
/**
* Execute the migration
*/
public function migrate(Request $request)
{
$request->validate([
'store_mapping' => 'required|array',
'store_mapping.*' => 'required|integer',
]);
try {
$storeMapping = $request->input('store_mapping');
// Convert array format: ['m1_store_id' => 'm2_store_id']
$mapping = [];
foreach ($storeMapping as $m1StoreId => $m2StoreId) {
$mapping[(int)$m1StoreId] = (int)$m2StoreId;
}
$result = $this->migrationService->migrateCategories($mapping);
return response()->json($result);
} catch (\Exception $e) {
Log::error('Migration error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Migration failed: ' . $e->getMessage(),
], 500);
}
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers;
use App\Services\MagentoCategoryMigrationService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ProductsController extends Controller
{
protected $migrationService;
public function __construct(MagentoCategoryMigrationService $migrationService)
{
$this->migrationService = $migrationService;
}
/**
* Show the products page
*/
public function index()
{
$m1Products = $this->migrationService->getMagento1Products();
$m2Products = $this->migrationService->getMagento2Products();
$m1ProductsNotInM2 = $this->migrationService->getM1ProductsNotInM2();
$m2ProductsNotInM1 = $this->migrationService->getM2ProductsNotInM1();
return view('products.index', [
'm1Products' => $m1Products,
'm2Products' => $m2Products,
'm1ProductsNotInM2' => $m1ProductsNotInM2,
'm2ProductsNotInM1' => $m2ProductsNotInM1,
]);
}
/**
* Migrate all products from Magento 1 to Magento 2
*/
public function migrateProducts(Request $request)
{
try {
$dryRun = $request->input('dry_run', false);
$result = $this->migrationService->migrateProducts($dryRun);
return response()->json($result, $result['success'] ? 200 : 400);
} catch (\Exception $e) {
Log::error('Product migration error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Migration failed: ' . $e->getMessage(),
'added' => 0,
'updated' => 0,
'errors' => 0,
'log' => [],
'missing_attributes' => []
], 500);
}
}
/**
* Delete a single product from Magento 2
*/
public function deleteM2Product(Request $request, $productId)
{
try {
$result = $this->migrationService->deleteM2Product($productId);
return response()->json($result, $result['success'] ? 200 : 400);
} catch (\Exception $e) {
Log::error('Delete M2 product error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Deletion failed: ' . $e->getMessage()
], 500);
}
}
/**
* Sync product category assignments from M1 to M2
*/
public function syncProductCategories(Request $request)
{
try {
$result = $this->migrationService->syncProductCategories();
return response()->json($result, $result['success'] ? 200 : 400);
} catch (\Exception $e) {
Log::error('Sync product categories error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Sync failed: ' . $e->getMessage(),
'updated' => 0,
'skipped' => 0,
'errors' => 0,
'log' => []
], 500);
}
}
/**
* Delete all products in Magento 2 that have entity_id greater than max M1 product ID
*/
public function deleteM2ProductsAboveM1Max(Request $request)
{
try {
$result = $this->migrationService->deleteM2ProductsAboveM1Max();
return response()->json($result, $result['success'] ? 200 : 400);
} catch (\Exception $e) {
Log::error('Delete M2 products error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Deletion failed: ' . $e->getMessage(),
'deleted' => 0
], 500);
}
}
}

2378
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,10 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import './base.css';
@import './connections.css';
@import './categories.css';
@import './migration.css';
@import './attributes.css';
@import './products.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';

View File

@ -0,0 +1,31 @@
/* Attributes page specific styles */
.attributes-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 6px;
}
.attributes-table thead tr {
background: #f8f9fa;
border-bottom: 2px solid #dee2e6;
}
.attributes-table th {
padding: 10px;
text-align: left;
font-weight: 600;
color: #333;
font-size: 0.9em;
}
.attributes-table td {
padding: 8px 10px;
font-size: 0.85em;
border-bottom: 1px solid #e0e0e0;
}
.attributes-table tbody tr:hover {
background: #f8f9fa;
}

239
resources/css/base.css Normal file
View File

@ -0,0 +1,239 @@
@import 'tailwindcss';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
width: 90%;
max-width: 90%;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 1.1em;
}
.content {
padding: 30px;
}
.section {
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.section h2 {
color: #333;
margin-bottom: 15px;
font-size: 1.5em;
}
.btn {
padding: 12px 30px;
border: none;
border-radius: 6px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-danger {
background: #d32f2f;
color: white;
}
.btn-danger:hover {
background: #b71c1c;
}
.actions {
display: flex;
gap: 15px;
margin-top: 20px;
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196F3;
padding: 15px;
border-radius: 4px;
margin-top: 15px;
}
.info-box p {
margin: 5px 0;
color: #1976D2;
}
.loading {
display: none;
text-align: center;
padding: 20px;
}
.loading.active {
display: block;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-card .number {
font-size: 2em;
font-weight: bold;
color: #667eea;
}
.stat-card .label {
color: #666;
margin-top: 5px;
}
.log-container {
background: #1e1e1e;
color: #d4d4d4;
padding: 20px;
border-radius: 6px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 0.9em;
margin-top: 15px;
display: none;
}
.log-container.active {
display: block;
}
.log-entry {
margin-bottom: 5px;
padding: 5px 0;
}
.log-entry.error {
color: #f48771;
}
.log-entry.success {
color: #4ec9b0;
}
/* Navigation */
.navigation {
display: flex;
border-bottom: 2px solid #ddd;
margin-bottom: 20px;
background: #f8f9fa;
border-radius: 8px 8px 0 0;
}
.nav-link {
padding: 15px 30px;
text-decoration: none;
border: none;
background: transparent;
font-size: 1em;
font-weight: 600;
color: #666;
transition: all 0.3s;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
}
.nav-link:hover {
background: #e9ecef;
color: #333;
}
.nav-link.active {
color: #667eea;
border-bottom-color: #667eea;
background: white;
}

View File

@ -0,0 +1,313 @@
/* Categories page specific styles */
/* Tree View */
.tree-container {
background: white;
border: 1px solid #ddd;
border-radius: 6px;
padding: 20px;
max-height: 600px;
overflow-y: auto;
}
.tree-node {
margin: 5px 0;
}
.tree-node-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
user-select: none;
transition: background 0.2s;
}
.tree-node-item:hover {
background: #f0f0f0;
}
.tree-toggle {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 8px;
font-size: 12px;
color: #666;
cursor: pointer;
}
.tree-toggle.expanded::before {
content: '▼';
}
.tree-toggle.collapsed::before {
content: '▶';
}
.tree-toggle.leaf {
width: 20px;
margin-right: 8px;
}
.tree-label {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
}
.tree-label-text {
font-weight: 500;
color: #333;
}
.tree-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75em;
font-weight: 600;
}
.tree-badge.active {
background: #d4edda;
color: #155724;
}
.tree-badge.inactive {
background: #f8d7da;
color: #721c24;
}
.tree-children {
margin-left: 28px;
border-left: 2px solid #e0e0e0;
padding-left: 12px;
display: none;
}
.tree-children.expanded {
display: block;
}
.tree-loading {
text-align: center;
padding: 40px;
color: #666;
}
.tree-error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 6px;
margin: 10px 0;
}
.tree-delete-btn {
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.75em;
cursor: pointer;
margin-left: 8px;
transition: background 0.2s;
}
.tree-delete-btn:hover {
background: #c82333;
}
.tree-delete-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.tree-rename-btn {
background: #28a745;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.75em;
cursor: pointer;
margin-left: 8px;
transition: background 0.2s;
}
.tree-rename-btn:hover {
background: #218838;
}
.tree-rename-input {
padding: 4px 8px;
border: 2px solid #28a745;
border-radius: 4px;
font-size: 0.9em;
width: 200px;
margin-left: 8px;
}
.tree-rename-input:focus {
outline: none;
border-color: #218838;
}
.tree-rename-actions {
display: inline-flex;
gap: 5px;
margin-left: 8px;
}
.tree-rename-save-btn, .tree-rename-cancel-btn {
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 0.75em;
cursor: pointer;
transition: background 0.2s;
}
.tree-rename-save-btn {
background: #28a745;
color: white;
}
.tree-rename-save-btn:hover {
background: #218838;
}
.tree-rename-cancel-btn {
background: #6c757d;
color: white;
}
.tree-rename-cancel-btn:hover {
background: #5a6268;
}
/* Delete Confirmation Popup */
.delete-confirm-popup {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 20px;
min-width: 320px;
max-width: 400px;
z-index: 10000;
border: 1px solid #e0e0e0;
animation: popupFadeIn 0.2s ease-out;
overflow: visible;
}
.delete-confirm-popup.no-arrow::before,
.delete-confirm-popup.no-arrow::after {
display: none;
}
@keyframes popupFadeIn {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.delete-confirm-popup::before {
content: '';
position: absolute;
bottom: -8px;
left: 20px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid white;
}
.delete-confirm-popup::after {
content: '';
position: absolute;
bottom: -9px;
left: 20px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #e0e0e0;
}
.delete-confirm-popup h3 {
margin: 0 0 12px 0;
color: #d32f2f;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 8px;
}
.delete-confirm-popup h3::before {
content: '⚠️';
font-size: 1.2em;
}
.delete-confirm-popup p {
margin: 0 0 16px 0;
color: #666;
line-height: 1.5;
font-size: 0.95em;
}
.delete-confirm-popup .popup-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.delete-confirm-popup .popup-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 0.9em;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.delete-confirm-popup .popup-btn-cancel {
background: #f5f5f5;
color: #333;
}
.delete-confirm-popup .popup-btn-cancel:hover {
background: #e0e0e0;
}
.delete-confirm-popup .popup-btn-delete {
background: #d32f2f;
color: white;
}
.delete-confirm-popup .popup-btn-delete:hover {
background: #b71c1c;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
z-index: 9999;
}

View File

@ -0,0 +1,27 @@
/* Connections page specific styles */
.connection-status {
display: flex;
gap: 20px;
margin-top: 15px;
}
.status-badge {
padding: 10px 20px;
border-radius: 6px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
}
.status-badge.success {
background: #d4edda;
color: #155724;
}
.status-badge.error {
background: #f8d7da;
color: #721c24;
}

View File

@ -0,0 +1,30 @@
/* Migration page specific styles */
.store-mapping {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 15px;
align-items: center;
margin-top: 15px;
}
.store-select {
padding: 12px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 1em;
width: 100%;
background: white;
}
.store-select:focus {
outline: none;
border-color: #667eea;
}
.arrow {
font-size: 1.5em;
color: #667eea;
text-align: center;
}

View File

@ -0,0 +1,31 @@
/* Products page specific styles */
.products-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 6px;
}
.products-table thead tr {
background: #f8f9fa;
border-bottom: 2px solid #dee2e6;
}
.products-table th {
padding: 10px;
text-align: left;
font-weight: 600;
color: #333;
font-size: 0.9em;
}
.products-table td {
padding: 8px 10px;
font-size: 0.85em;
border-bottom: 1px solid #e0e0e0;
}
.products-table tbody tr:hover {
background: #f8f9fa;
}

118
resources/js/attributes.js Normal file
View File

@ -0,0 +1,118 @@
// Attributes page JavaScript
let routes = {};
let csrfToken = '';
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
if (window.attributeRoutes) {
routes = window.attributeRoutes;
csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
}
});
function migrateAttribute(attributeId, attributeCode) {
if (!confirm(`Are you sure you want to migrate the attribute "${attributeCode}" from Magento 1 to Magento 2?`)) {
return;
}
const button = document.getElementById(`migrate-btn-${attributeId}`);
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Migrating...';
const url = routes.migrateAttribute.replace(':id', attributeId);
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const row = document.getElementById(`attr-row-${attributeId}`);
if (row) {
row.style.opacity = '0.5';
row.style.background = '#d4edda';
button.textContent = 'Migrated ✓';
button.style.background = '#28a745';
button.disabled = true;
const totalSpan = document.getElementById('missingAttributesTotal');
if (totalSpan) {
const currentCount = parseInt(totalSpan.textContent);
if (currentCount > 0) {
totalSpan.textContent = currentCount - 1;
}
}
}
alert('Attribute migrated successfully!');
} else {
button.disabled = false;
button.textContent = originalText;
alert('Error: ' + (data.message || 'Failed to migrate attribute'));
}
})
.catch(error => {
button.disabled = false;
button.textContent = originalText;
alert('Error: ' + error.message);
});
}
function migrateAttributeGroup(groupId, setId, groupName) {
if (!confirm(`Are you sure you want to migrate the attribute group "${groupName}" from Magento 1 to Magento 2?\n\nThis will also migrate any attributes in this group that exist in Magento 2.`)) {
return;
}
const button = document.getElementById(`migrate-group-btn-${groupId}-${setId}`);
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Migrating...';
const url = routes.migrateAttributeGroup
.replace(':groupId', groupId)
.replace(':setId', setId);
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const row = document.getElementById(`group-row-${groupId}-${setId}`);
if (row) {
row.style.opacity = '0.5';
row.style.background = '#d4edda';
button.textContent = 'Migrated ✓';
button.style.background = '#28a745';
button.disabled = true;
}
const message = data.attributes_migrated !== undefined
? `Attribute group migrated successfully! ${data.attributes_migrated} attribute(s) were also migrated to the group.`
: 'Attribute group migrated successfully!';
alert(message);
} else {
button.disabled = false;
button.textContent = originalText;
alert('Error: ' + (data.message || 'Failed to migrate attribute group'));
}
})
.catch(error => {
button.disabled = false;
button.textContent = originalText;
alert('Error: ' + error.message);
});
}
// Make functions available globally
window.migrateAttribute = migrateAttribute;
window.migrateAttributeGroup = migrateAttributeGroup;

268
resources/js/categories.js Normal file
View File

@ -0,0 +1,268 @@
// Categories page JavaScript
let routes = {};
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
if (window.categoryRoutes) {
routes = window.categoryRoutes;
loadCategoryTrees();
}
});
function loadCategoryTrees() {
loadM1Tree();
loadM2Tree();
}
function loadM1Tree() {
const container = document.getElementById('m1-tree-container');
if (!container) return;
container.innerHTML = '<div class="tree-loading">Loading categories...</div>';
fetch(routes.magento1CategoryTree)
.then(response => response.json())
.then(data => {
if (data.success) {
container.innerHTML = '';
if (data.tree && data.tree.length > 0) {
renderTree(container, data.tree, 'm1');
} else {
container.innerHTML = '<div class="tree-loading">No categories found</div>';
}
} else {
container.innerHTML = `<div class="tree-error">Error: ${data.message || 'Failed to load categories'}</div>`;
}
})
.catch(error => {
container.innerHTML = `<div class="tree-error">Error: ${error.message}</div>`;
});
}
function loadM2Tree() {
const container = document.getElementById('m2-tree-container');
if (!container) return;
container.innerHTML = '<div class="tree-loading">Loading categories...</div>';
fetch(routes.magento2CategoryTree)
.then(response => response.json())
.then(data => {
if (data.success) {
container.innerHTML = '';
if (data.tree && data.tree.length > 0) {
renderTree(container, data.tree, 'm2');
} else {
container.innerHTML = '<div class="tree-loading">No categories found</div>';
}
} else {
container.innerHTML = `<div class="tree-error">Error: ${data.message || 'Failed to load categories'}</div>`;
}
})
.catch(error => {
container.innerHTML = `<div class="tree-error">Error: ${error.message}</div>`;
});
}
function renderTree(container, nodes, source = 'm2') {
nodes.forEach(node => {
const nodeElement = createTreeNode(node, source);
container.appendChild(nodeElement);
});
}
function createTreeNode(node, source = 'm2') {
const nodeDiv = document.createElement('div');
nodeDiv.className = 'tree-node';
const hasChildren = node.children && node.children.length > 0;
const canDelete = node.id != 0 && node.id != 1;
const itemDiv = document.createElement('div');
itemDiv.className = 'tree-node-item';
const toggle = document.createElement('span');
toggle.className = hasChildren ? 'tree-toggle collapsed' : 'tree-toggle leaf';
if (hasChildren) {
toggle.onclick = function(e) {
e.stopPropagation();
toggleNode(this);
};
}
const label = document.createElement('div');
label.className = 'tree-label';
const labelText = document.createElement('span');
labelText.className = 'tree-label-text';
labelText.textContent = `[${node.id}] ${node.name || 'Unnamed Category'}`;
const badge = document.createElement('span');
badge.className = `tree-badge ${node.is_active ? 'active' : 'inactive'}`;
badge.textContent = node.is_active ? 'Active' : 'Inactive';
label.appendChild(labelText);
label.appendChild(badge);
if (canDelete) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'tree-delete-btn';
deleteBtn.textContent = 'Delete';
deleteBtn.onclick = function(e) {
e.stopPropagation();
showDeleteConfirmPopup(deleteBtn, node.id, node.name, source, hasChildren, node.children ? node.children.length : 0);
};
label.appendChild(deleteBtn);
}
itemDiv.appendChild(toggle);
itemDiv.appendChild(label);
nodeDiv.appendChild(itemDiv);
if (hasChildren) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'tree-children';
node.children.forEach(child => {
childrenDiv.appendChild(createTreeNode(child, source));
});
nodeDiv.appendChild(childrenDiv);
}
return nodeDiv;
}
function toggleNode(toggleElement) {
const nodeItem = toggleElement.parentElement;
const nodeDiv = nodeItem.parentElement;
const childrenDivs = nodeDiv.querySelectorAll('.tree-children');
if (childrenDivs.length > 0) {
let isExpanded = false;
childrenDivs.forEach(div => {
if (div.style.display !== 'none') {
isExpanded = true;
}
});
if (isExpanded) {
childrenDivs.forEach(div => {
div.style.display = 'none';
});
toggleElement.classList.remove('expanded');
toggleElement.classList.add('collapsed');
} else {
childrenDivs.forEach(div => {
div.style.display = 'block';
});
toggleElement.classList.remove('collapsed');
toggleElement.classList.add('expanded');
}
}
}
function showDeleteConfirmPopup(buttonElement, categoryId, categoryName, source, hasChildren, childrenCount) {
const existingPopup = document.querySelector('.delete-confirm-popup');
const existingOverlay = document.querySelector('.popup-overlay');
if (existingPopup) existingPopup.remove();
if (existingOverlay) existingOverlay.remove();
const overlay = document.createElement('div');
overlay.className = 'popup-overlay';
overlay.onclick = function() {
closeDeleteConfirmPopup();
};
document.body.appendChild(overlay);
const popup = document.createElement('div');
popup.className = 'delete-confirm-popup';
popup.style.position = 'fixed';
popup.style.top = '50%';
popup.style.left = '50%';
popup.style.transform = 'translate(-50%, -50%)';
popup.style.zIndex = '10000';
const title = document.createElement('h3');
title.textContent = 'Delete Category';
const message = document.createElement('p');
if (hasChildren) {
message.innerHTML = `Are you sure you want to delete <strong style="color: #d32f2f; font-weight: bold;">"${categoryName}"</strong> and all ${childrenCount} subcategory(ies)? This action cannot be undone.`;
} else {
message.innerHTML = `Are you sure you want to delete the category <strong style="color: #d32f2f; font-weight: bold;">"${categoryName}"</strong>? This action cannot be undone.`;
}
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'popup-buttons';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'popup-btn popup-btn-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = function(e) {
e.stopPropagation();
closeDeleteConfirmPopup();
};
const deleteBtn = document.createElement('button');
deleteBtn.className = 'popup-btn popup-btn-delete';
deleteBtn.textContent = 'Delete';
deleteBtn.onclick = function(e) {
e.stopPropagation();
closeDeleteConfirmPopup();
deleteCategory(categoryId, categoryName, source);
};
buttonsDiv.appendChild(cancelBtn);
buttonsDiv.appendChild(deleteBtn);
popup.appendChild(title);
popup.appendChild(message);
popup.appendChild(buttonsDiv);
document.body.appendChild(popup);
}
function closeDeleteConfirmPopup() {
const popup = document.querySelector('.delete-confirm-popup');
const overlay = document.querySelector('.popup-overlay');
if (popup) popup.remove();
if (overlay) overlay.remove();
}
function deleteCategory(categoryId, categoryName, source) {
const container = source === 'm1' ? document.getElementById('m1-tree-container') : document.getElementById('m2-tree-container');
if (!container) return;
const originalContent = container.innerHTML;
container.innerHTML = '<div class="tree-loading">Deleting category...</div>';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const deleteRoute = routes.deleteCategory.replace(':id', categoryId);
fetch(deleteRoute, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({ source: source })
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (source === 'm1') {
loadM1Tree();
} else {
loadM2Tree();
location.reload();
}
} else {
container.innerHTML = originalContent;
alert('Error: ' + (data.message || 'Failed to delete category'));
}
})
.catch(error => {
container.innerHTML = originalContent;
alert('Error: ' + error.message);
});
}

View File

@ -0,0 +1,28 @@
// Connections page JavaScript
export function initConnections() {
const testRoute = document.querySelector('[data-test-route]')?.dataset.testRoute;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
window.testConnections = function() {
fetch(testRoute, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✓ Both database connections are working!');
location.reload();
} else {
alert('✗ Connection test failed. Check the error messages above.');
}
})
.catch(error => {
alert('Error testing connections: ' + error.message);
});
};
}

101
resources/js/migration.js Normal file
View File

@ -0,0 +1,101 @@
// Migration page JavaScript
let routes = {};
let csrfToken = '';
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
if (window.migrationRoutes) {
routes = window.migrationRoutes;
csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
}
});
function startMigration() {
const storeMapping = {};
const selects = document.querySelectorAll('select[name^="store_mapping"]');
let hasMapping = false;
selects.forEach(select => {
if (select.value) {
storeMapping[select.name.match(/\[(\d+)\]/)[1]] = select.value;
hasMapping = true;
}
});
if (!hasMapping) {
alert('Please map at least one store before starting migration.');
return;
}
if (!confirm('Are you sure you want to start the migration? This will modify your Magento 2 database.')) {
return;
}
document.getElementById('migrateBtn').disabled = true;
document.getElementById('loading').classList.add('active');
const migrationLogContent = document.getElementById('migrationLogContent');
migrationLogContent.innerHTML = '<div class="log-entry">Starting migration...</div>';
document.getElementById('migrationLogContainer').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
fetch(routes.migrate, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({ store_mapping: storeMapping })
})
.then(response => response.json())
.then(data => {
document.getElementById('loading').classList.remove('active');
document.getElementById('migrateBtn').disabled = false;
if (data.success) {
addLogEntry(`✓ Migration completed successfully!`, 'success');
addLogEntry(`Total categories processed: ${data.migrated_count}`, 'success');
if (data.added_count !== undefined) {
addLogEntry(` - Added new categories: ${data.added_count}`, 'success');
}
if (data.existing_count !== undefined) {
addLogEntry(` - Found existing categories: ${data.existing_count}`, 'success');
}
if (data.log && data.log.length > 0) {
data.log.forEach(log => {
const type = log.includes('ERROR') ? 'error' : 'success';
addLogEntry(log, type);
});
}
} else {
addLogEntry(`✗ Migration failed: ${data.message}`, 'error');
if (data.log && data.log.length > 0) {
data.log.forEach(log => {
addLogEntry(log, log.includes('ERROR') ? 'error' : 'success');
});
}
}
})
.catch(error => {
document.getElementById('loading').classList.remove('active');
document.getElementById('migrateBtn').disabled = false;
addLogEntry(`✗ Error: ${error.message}`, 'error');
});
}
function addLogEntry(message, type = '') {
const migrationLogContent = document.getElementById('migrationLogContent');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = message;
migrationLogContent.appendChild(entry);
const migrationLogContainer = document.getElementById('migrationLogContainer');
migrationLogContainer.scrollTop = migrationLogContainer.scrollHeight;
}
// Make startMigration available globally
window.startMigration = startMigration;

358
resources/js/products.js Normal file
View File

@ -0,0 +1,358 @@
// Products page JavaScript
let routes = {};
let csrfToken = '';
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
if (window.productRoutes) {
routes = window.productRoutes;
csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
}
});
function startProductMigration(dryRun) {
const button = dryRun ? document.getElementById('dryRunProductMigrationBtn') : document.getElementById('startProductMigrationBtn');
const otherButton = dryRun ? document.getElementById('startProductMigrationBtn') : document.getElementById('dryRunProductMigrationBtn');
const originalText = button.textContent;
button.disabled = true;
otherButton.disabled = true;
button.textContent = dryRun ? 'Running Dry Run...' : 'Migrating...';
button.style.cursor = 'not-allowed';
const logContent = document.getElementById('productMigrationLogContent');
logContent.innerHTML = '<div class="log-entry">' + (dryRun ? 'Running dry run...' : 'Starting migration...') + '</div>';
fetch(routes.migrateProducts, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({ dry_run: dryRun })
})
.then(response => response.json())
.then(data => {
button.disabled = false;
otherButton.disabled = false;
button.textContent = originalText;
button.style.cursor = 'pointer';
if (data.success) {
const successEntry = document.createElement('div');
successEntry.className = 'log-entry success';
successEntry.textContent = `${dryRun ? 'Dry run' : 'Migration'} completed! Added: ${data.added || 0}, Updated: ${data.updated || 0}, Errors: ${data.errors || 0}`;
logContent.appendChild(successEntry);
if (data.log && data.log.length > 0) {
data.log.forEach(log => {
const entry = document.createElement('div');
entry.className = 'log-entry ' + (log.includes('ERROR') ? 'error' : 'success');
entry.textContent = log;
logContent.appendChild(entry);
});
}
if (data.missing_attributes && data.missing_attributes.length > 0) {
const missingSection = document.getElementById('missingAttributesSection');
const tbody = document.getElementById('missingAttributesTableBody');
tbody.innerHTML = '';
data.missing_attributes.forEach(attr => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${attr.attribute_code}</td>
<td>${attr.frontend_label || 'N/A'}</td>
<td>${attr.backend_type || 'N/A'}</td>
`;
tbody.appendChild(row);
});
missingSection.style.display = 'block';
}
const logContainer = document.getElementById('productMigrationLogContainer');
logContainer.scrollTop = logContainer.scrollHeight;
} else {
const errorEntry = document.createElement('div');
errorEntry.className = 'log-entry error';
errorEntry.textContent = '✗ ' + (dryRun ? 'Dry run' : 'Migration') + ' failed: ' + (data.message || 'Unknown error');
logContent.appendChild(errorEntry);
}
})
.catch(error => {
button.disabled = false;
otherButton.disabled = false;
button.textContent = originalText;
button.style.cursor = 'pointer';
const errorEntry = document.createElement('div');
errorEntry.className = 'log-entry error';
errorEntry.textContent = '✗ Error: ' + error.message;
logContent.appendChild(errorEntry);
});
}
function deleteM2Product(productId, productName, productSku) {
if (!confirm(`Are you sure you want to delete product "${productName}" (SKU: ${productSku})?`)) {
return;
}
const url = routes.deleteProduct.replace(':id', productId);
fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Product deleted successfully!');
location.reload();
} else {
alert('Error: ' + (data.message || 'Failed to delete product'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
function deleteProductsAboveM1Max() {
if (!confirm('Are you sure you want to delete all Magento 2 products with entity_id greater than the maximum Magento 1 product ID? This action cannot be undone.')) {
return;
}
const button = document.getElementById('deleteProductsAboveM1MaxBtn');
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Deleting...';
fetch(routes.deleteProductsAboveM1Max, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
})
.then(response => response.json())
.then(data => {
button.disabled = false;
button.textContent = originalText;
if (data.success) {
alert(`Successfully deleted ${data.deleted || 0} products.`);
location.reload();
} else {
alert('Error: ' + (data.message || 'Failed to delete products'));
}
})
.catch(error => {
button.disabled = false;
button.textContent = originalText;
alert('Error: ' + error.message);
});
}
function syncProductCategories() {
if (!confirm('Are you sure you want to sync product category assignments from Magento 1 to Magento 2?')) {
return;
}
const button = document.getElementById('syncProductCategoriesBtn');
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Syncing...';
fetch(routes.syncProductCategories, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
})
.then(response => response.json())
.then(data => {
button.disabled = false;
button.textContent = originalText;
if (data.success) {
alert(`Sync completed! Updated: ${data.updated || 0}, Skipped: ${data.skipped || 0}, Errors: ${data.errors || 0}`);
} else {
alert('Error: ' + (data.message || 'Sync failed'));
}
})
.catch(error => {
button.disabled = false;
button.textContent = originalText;
alert('Error: ' + error.message);
});
}
function loadM1CategoryTreeWithProducts() {
const container = document.getElementById('m1-category-products-tree-container');
if (!container) return;
container.innerHTML = '<div class="tree-loading">Loading categories and products...</div>';
fetch(routes.magento1CategoryTreeWithProducts)
.then(response => response.json())
.then(data => {
if (data.success) {
container.innerHTML = '';
if (data.tree && data.tree.length > 0) {
renderCategoryTreeWithProducts(container, data.tree);
} else {
container.innerHTML = '<div class="tree-loading">No categories found</div>';
}
} else {
container.innerHTML = `<div class="tree-error">Error: ${data.message || 'Failed to load categories'}</div>`;
}
})
.catch(error => {
container.innerHTML = `<div class="tree-error">Error: ${error.message}</div>`;
});
}
function loadM2CategoryTreeWithProducts() {
const container = document.getElementById('m2-category-products-tree-container');
if (!container) return;
container.innerHTML = '<div class="tree-loading">Loading categories and products...</div>';
fetch(routes.magento2CategoryTreeWithProducts)
.then(response => response.json())
.then(data => {
if (data.success) {
container.innerHTML = '';
if (data.tree && data.tree.length > 0) {
renderCategoryTreeWithProducts(container, data.tree);
} else {
container.innerHTML = '<div class="tree-loading">No categories found</div>';
}
} else {
container.innerHTML = `<div class="tree-error">Error: ${data.message || 'Failed to load categories'}</div>`;
}
})
.catch(error => {
container.innerHTML = `<div class="tree-error">Error: ${error.message}</div>`;
});
}
function renderCategoryTreeWithProducts(container, tree) {
tree.forEach(node => {
const nodeElement = createCategoryWithProductsNode(node);
container.appendChild(nodeElement);
});
}
function toggleNode(toggleElement) {
const nodeItem = toggleElement.parentElement;
const nodeDiv = nodeItem.parentElement;
const childrenDivs = nodeDiv.querySelectorAll('.tree-children');
if (childrenDivs.length > 0) {
let isExpanded = false;
childrenDivs.forEach(div => {
if (div.style.display !== 'none') {
isExpanded = true;
}
});
if (isExpanded) {
childrenDivs.forEach(div => {
div.style.display = 'none';
});
toggleElement.classList.remove('expanded');
toggleElement.classList.add('collapsed');
} else {
childrenDivs.forEach(div => {
div.style.display = 'block';
});
toggleElement.classList.remove('collapsed');
toggleElement.classList.add('expanded');
}
}
}
function createCategoryWithProductsNode(node) {
const nodeDiv = document.createElement('div');
nodeDiv.className = 'tree-node';
const hasChildren = node.children && node.children.length > 0;
const hasProducts = node.products && node.products.length > 0;
const productCount = node.product_count !== undefined ? node.product_count : (node.products ? node.products.length : 0);
const itemDiv = document.createElement('div');
itemDiv.className = 'tree-node-item';
const toggle = document.createElement('span');
toggle.className = (hasChildren || hasProducts) ? 'tree-toggle collapsed' : 'tree-toggle leaf';
if (hasChildren || hasProducts) {
toggle.onclick = function(e) {
e.stopPropagation();
toggleNode(this);
};
}
const label = document.createElement('div');
label.className = 'tree-label';
const labelText = document.createElement('span');
labelText.className = 'tree-label-text';
labelText.textContent = `[${node.id}] ${node.name || 'Unnamed Category'} (${productCount} products)`;
const badge = document.createElement('span');
badge.className = `tree-badge ${node.is_active ? 'active' : 'inactive'}`;
badge.textContent = node.is_active ? 'Active' : 'Inactive';
label.appendChild(labelText);
label.appendChild(badge);
itemDiv.appendChild(toggle);
itemDiv.appendChild(label);
nodeDiv.appendChild(itemDiv);
// Add products section if products exist
if (hasProducts) {
const productsDiv = document.createElement('div');
productsDiv.className = 'tree-children';
productsDiv.style.display = 'none';
const productsHeader = document.createElement('div');
productsHeader.style.cssText = 'padding: 8px 12px; font-weight: 600; color: #667eea; background: #f0f0f0; border-radius: 4px; margin: 5px 0;';
productsHeader.textContent = `Products (${node.products.length}):`;
productsDiv.appendChild(productsHeader);
node.products.forEach(product => {
const productDiv = document.createElement('div');
productDiv.style.cssText = 'padding: 6px 12px 6px 30px; font-size: 0.9em; color: #666; border-left: 2px solid #e0e0e0; margin-left: 20px;';
productDiv.textContent = `ID: ${product.id || product.product_id || 'N/A'} | SKU: ${product.sku || 'N/A'} | Name: ${product.name || 'Unnamed Product'}`;
productsDiv.appendChild(productDiv);
});
nodeDiv.appendChild(productsDiv);
}
// Add children
if (hasChildren) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'tree-children';
childrenDiv.style.display = 'none';
node.children.forEach(child => {
childrenDiv.appendChild(createCategoryWithProductsNode(child));
});
nodeDiv.appendChild(childrenDiv);
}
return nodeDiv;
}
// Make functions available globally
window.startProductMigration = startProductMigration;
window.deleteM2Product = deleteM2Product;
window.deleteProductsAboveM1Max = deleteProductsAboveM1Max;
window.syncProductCategories = syncProductCategories;
window.loadM1CategoryTreeWithProducts = loadM1CategoryTreeWithProducts;
window.loadM2CategoryTreeWithProducts = loadM2CategoryTreeWithProducts;

View File

@ -0,0 +1,274 @@
@extends('layouts.app')
@section('content')
<!-- Attribute Groups Section -->
<div class="section">
<h2>📦 Attribute Groups</h2>
<p>View attribute groups that organize attributes within attribute sets:</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
<!-- Magento 1 Attribute Groups -->
<div>
<h3 style="margin-bottom: 15px; color: #667eea;">Magento 1 Attribute Groups ({{ $m1AttributeGroups->count() }})</h3>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 600px; overflow-y: auto;">
@if($m1AttributeGroups->count() > 0)
<table class="attributes-table">
<thead>
<tr>
<th>Group ID</th>
<th>Group Name</th>
<th>Attribute Set</th>
<th>Attributes</th>
<th>Sort Order</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($m1AttributeGroups as $group)
@php
$groupKey = ($group->attribute_set_name ?? 'Default') . '|' . ($group->attribute_group_name ?? '');
$isMissing = $m1AttributeGroupsMissingInM2->contains(function($missingGroup) use ($groupKey) {
return ($missingGroup->attribute_set_name ?? 'Default') . '|' . ($missingGroup->attribute_group_name ?? '') === $groupKey;
});
@endphp
<tr id="group-row-{{ $group->attribute_group_id }}-{{ $group->attribute_set_id }}" style="{{ $isMissing ? 'background: #fff3cd;' : '' }}">
<td>{{ $group->attribute_group_id }}</td>
<td>{{ $group->attribute_group_name ?? 'N/A' }}</td>
<td>{{ $group->attribute_set_name ?? 'Default' }}</td>
<td>{{ $group->attribute_count ?? 0 }}</td>
<td>{{ $group->sort_order ?? 0 }}</td>
<td>
@if($isMissing)
<button
class="btn btn-primary"
onclick="migrateAttributeGroup({{ $group->attribute_group_id }}, {{ $group->attribute_set_id }}, '{{ $group->attribute_group_name ?? 'N/A' }}')"
id="migrate-group-btn-{{ $group->attribute_group_id }}-{{ $group->attribute_set_id }}"
style="padding: 6px 12px; font-size: 0.85em;">
Migrate
</button>
@else
<span style="color: #28a745;"> Exists</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div style="text-align: center; padding: 40px; color: #999;">
No attribute groups found.
</div>
@endif
</div>
</div>
<!-- Magento 2 Attribute Groups -->
<div>
<h3 style="margin-bottom: 15px; color: #764ba2;">Magento 2 Attribute Groups ({{ $m2AttributeGroups->count() }})</h3>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 600px; overflow-y: auto;">
@if($m2AttributeGroups->count() > 0)
<table class="attributes-table">
<thead>
<tr>
<th>Group ID</th>
<th>Group Name</th>
<th>Attribute Set</th>
<th>Attributes</th>
<th>Sort Order</th>
</tr>
</thead>
<tbody>
@foreach($m2AttributeGroups as $group)
<tr>
<td>{{ $group->attribute_group_id }}</td>
<td>{{ $group->attribute_group_name ?? 'N/A' }}</td>
<td>{{ $group->attribute_set_name ?? 'Default' }}</td>
<td>{{ $group->attribute_count ?? 0 }}</td>
<td>{{ $group->sort_order ?? 0 }}</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div style="text-align: center; padding: 40px; color: #999;">
No attribute groups found.
</div>
@endif
</div>
</div>
</div>
</div>
<!-- Missing Attributes Section -->
<div class="section" style="margin-top: 30px;">
<h2>⚠️ Missing Attributes</h2>
<p>These attributes exist in Magento 1 but are missing in Magento 2:</p>
@if($m1AttributesMissingInM2->count() > 0)
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 600px; overflow-y: auto; margin-top: 15px;">
<table class="attributes-table">
<thead>
<tr>
<th>ID</th>
<th>Code</th>
<th>Label</th>
<th>Type</th>
<th>Input</th>
<th>Required</th>
<th>User Defined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($m1AttributesMissingInM2 as $attr)
<tr id="attr-row-{{ $attr->attribute_id }}">
<td>{{ $attr->attribute_id }}</td>
<td style="font-weight: 500; color: #667eea;">{{ $attr->attribute_code }}</td>
<td>{{ $attr->frontend_label ?? 'N/A' }}</td>
<td style="color: #666;">{{ $attr->backend_type ?? 'N/A' }}</td>
<td style="color: #666;">{{ $attr->frontend_input ?? 'N/A' }}</td>
<td>
@if($attr->is_required ?? 0)
<span style="color: #d32f2f; font-weight: 600;">Yes</span>
@else
<span style="color: #666;">No</span>
@endif
</td>
<td>
@if($attr->is_user_defined ?? 0)
<span style="color: #1976D2; font-weight: 600;">Yes</span>
@else
<span style="color: #666;">No</span>
@endif
</td>
<td>
<button
class="btn btn-primary"
onclick="migrateAttribute({{ $attr->attribute_id }}, '{{ $attr->attribute_code }}')"
id="migrate-btn-{{ $attr->attribute_id }}"
style="padding: 6px 12px; font-size: 0.85em;">
Migrate
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div id="missingAttributesCount" style="margin-top: 15px; padding: 12px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
<strong>Total:</strong> <span id="missingAttributesTotal">{{ $m1AttributesMissingInM2->count() }}</span> {{ Str::plural('attribute', $m1AttributesMissingInM2->count()) }} found in Magento 1 but not in Magento 2.
</div>
@else
<div style="margin-top: 15px; padding: 15px; background: #d4edda; border-left: 4px solid #28a745; border-radius: 4px; color: #155724;">
All Magento 1 attributes have matching attribute codes in Magento 2.
</div>
@endif
</div>
<!-- Category Attributes Section -->
<div class="section" style="margin-top: 30px;">
<h2>📋 Category Attributes</h2>
<p>View all category attributes from Magento 1 and Magento 2</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
<!-- Magento 1 Attributes -->
<div>
<h3 style="margin-bottom: 15px; color: #667eea;">Magento 1 Attributes ({{ $m1Attributes->count() }})</h3>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 600px; overflow-y: auto;">
@if($m1Attributes->count() > 0)
<table class="attributes-table">
<thead>
<tr>
<th>ID</th>
<th>Code</th>
<th>Label</th>
<th>Type</th>
<th>Input</th>
<th>Required</th>
</tr>
</thead>
<tbody>
@foreach($m1Attributes as $attr)
<tr>
<td>{{ $attr->attribute_id }}</td>
<td style="font-weight: 500; color: #667eea;">{{ $attr->attribute_code }}</td>
<td>{{ $attr->frontend_label ?? 'N/A' }}</td>
<td style="color: #666;">{{ $attr->backend_type ?? 'N/A' }}</td>
<td style="color: #666;">{{ $attr->frontend_input ?? 'N/A' }}</td>
<td>
@if($attr->is_required ?? 0)
<span style="color: #d32f2f; font-weight: 600;">Yes</span>
@else
<span style="color: #666;">No</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div style="text-align: center; padding: 40px; color: #999;">
No attributes found.
</div>
@endif
</div>
</div>
<!-- Magento 2 Attributes -->
<div>
<h3 style="margin-bottom: 15px; color: #764ba2;">Magento 2 Attributes ({{ $m2Attributes->count() }})</h3>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 600px; overflow-y: auto;">
@if($m2Attributes->count() > 0)
<table class="attributes-table">
<thead>
<tr>
<th>ID</th>
<th>Code</th>
<th>Label</th>
<th>Type</th>
<th>Input</th>
<th>Required</th>
</tr>
</thead>
<tbody>
@foreach($m2Attributes as $attr)
<tr>
<td>{{ $attr->attribute_id }}</td>
<td style="font-weight: 500; color: #764ba2;">{{ $attr->attribute_code }}</td>
<td>{{ $attr->frontend_label ?? 'N/A' }}</td>
<td style="color: #666;">{{ $attr->backend_type ?? 'N/A' }}</td>
<td style="color: #666;">{{ $attr->frontend_input ?? 'N/A' }}</td>
<td>
@if($attr->is_required ?? 0)
<span style="color: #d32f2f; font-weight: 600;">Yes</span>
@else
<span style="color: #666;">No</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div style="text-align: center; padding: 40px; color: #999;">
No attributes found.
</div>
@endif
</div>
</div>
</div>
</div>
@endsection
{{-- CSS is loaded globally via app.css --}}
@push('scripts')
@vite(['resources/js/attributes.js'])
<script>
window.attributeRoutes = {
migrateAttribute: '{{ route("attributes.migrate-attribute", ["attributeId" => ":id"]) }}',
migrateAttributeGroup: '{{ route("attributes.migrate-attribute-group", ["groupId" => ":groupId", "setId" => ":setId"]) }}'
};
</script>
@endpush

View File

@ -0,0 +1,123 @@
@extends('layouts.app')
@section('content')
<!-- Statistics -->
<div class="section">
<h2>📊 Category Statistics</h2>
<div class="stats">
<div class="stat-card">
<div class="number">{{ $m1CategoriesCount }}</div>
<div class="label">Magento 1 Categories</div>
</div>
<div class="stat-card">
<div class="number">{{ $m2CategoriesCount }}</div>
<div class="label">Magento 2 Categories</div>
</div>
<div class="stat-card">
<div class="number">{{ $m1CategoriesCount - $m2CategoriesCount }}</div>
<div class="label">Difference</div>
</div>
</div>
</div>
<!-- Missing Categories Section -->
<div class="section">
<h2>⚠️ Magento 2 Categories Not Found in Magento 1</h2>
<p>These categories exist in Magento 2 but do not have a matching name in Magento 1:</p>
@if($m2CategoriesNotInM1->count() > 0)
<div id="m2-categories-not-in-m1-table-wrapper" style="margin-top: 15px; max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse; background: white; border-radius: 6px;">
<thead>
<tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
<th style="padding: 12px; text-align: left; font-weight: 600; color: #333;">ID</th>
<th style="padding: 12px; text-align: left; font-weight: 600; color: #333;">Category Name</th>
<th style="padding: 12px; text-align: left; font-weight: 600; color: #333;">Level</th>
<th style="padding: 12px; text-align: left; font-weight: 600; color: #333;">Status</th>
<th style="padding: 12px; text-align: left; font-weight: 600; color: #333;">Root Category</th>
<th style="padding: 12px; text-align: left; font-weight: 600; color: #333;">Path</th>
<th style="padding: 12px; text-align: left; font-weight: 600; color: #333;">Actions</th>
</tr>
</thead>
<tbody id="m2-categories-not-in-m1-tbody">
@foreach($m2CategoriesNotInM1 as $category)
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px 12px;">{{ $category->entity_id }}</td>
<td style="padding: 10px 12px; font-weight: 500;">{{ $category->name ?? 'Unnamed Category' }}</td>
<td style="padding: 10px 12px;">{{ $category->level ?? 'N/A' }}</td>
<td style="padding: 10px 12px;">
<span class="tree-badge {{ ($category->is_active ?? 0) ? 'active' : 'inactive' }}">
{{ ($category->is_active ?? 0) ? 'Active' : 'Inactive' }}
</span>
</td>
<td style="padding: 10px 12px;">
@if(isset($category->root_category_name) && $category->root_category_name !== 'N/A')
<span style="font-weight: 500;">{{ $category->root_category_name }}</span>
@if(isset($category->root_category_id))
<span style="font-size: 0.85em; color: #666;">(ID: {{ $category->root_category_id }})</span>
@endif
@else
<span style="color: #999;">N/A</span>
@endif
</td>
<td style="padding: 10px 12px; font-size: 0.9em; color: #666;">{{ $category->path ?? 'N/A' }}</td>
<td style="padding: 10px 12px;">
<button
class="tree-delete-btn"
onclick="showDeleteConfirmPopup(this, {{ $category->entity_id }}, '{{ addslashes($category->name ?? 'Unnamed Category') }}', 'm2', false, 0)"
title="Delete this category">
Delete
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div id="m2-categories-not-in-m1-total" style="margin-top: 15px; padding: 12px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
<strong>Total:</strong> {{ $m2CategoriesNotInM1->count() }} {{ Str::plural('category', $m2CategoriesNotInM1->count()) }} found in Magento 2 but not in Magento 1.
</div>
@else
<div id="m2-categories-not-in-m1-empty" style="margin-top: 15px; padding: 15px; background: #d4edda; border-left: 4px solid #28a745; border-radius: 4px; color: #155724;">
All Magento 2 categories have matching names in Magento 1.
</div>
@endif
</div>
<div class="section">
<h2>🌳 Category Trees</h2>
<p>View category hierarchies from Magento 1 and Magento 2</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
<!-- Magento 1 Tree -->
<div>
<h3 style="margin-bottom: 15px; color: #667eea;">Magento 1 Categories</h3>
<div class="tree-container" id="m1-tree-container">
<div class="tree-loading">Loading categories...</div>
</div>
</div>
<!-- Magento 2 Tree -->
<div>
<h3 style="margin-bottom: 15px; color: #764ba2;">Magento 2 Categories</h3>
<div class="tree-container" id="m2-tree-container">
<div class="tree-loading">Loading categories...</div>
</div>
</div>
</div>
</div>
@endsection
{{-- CSS is loaded globally via app.css --}}
@push('scripts')
@vite(['resources/js/categories.js'])
<script>
window.categoryRoutes = {
magento1CategoryTree: '{{ route("categories.magento1-category-tree") }}',
magento2CategoryTree: '{{ route("categories.magento2-category-tree") }}',
deleteCategory: '{{ route("categories.delete-category", ["categoryId" => ":id"]) }}'
};
</script>
@endpush

View File

@ -0,0 +1,40 @@
@extends('layouts.app')
@section('content')
<div class="section">
<h2>📡 Database Connections</h2>
<div class="connection-status">
<div>
<strong>Magento 1:</strong>
<span class="status-badge {{ $connectionTest['magento1'] ? 'success' : 'error' }}">
{{ $connectionTest['magento1'] ? '✓ Connected' : '✗ Failed' }}
</span>
@if(!$connectionTest['magento1'] && isset($connectionTest['magento1_error']))
<div style="color: #721c24; margin-top: 5px; font-size: 0.9em;">
{{ $connectionTest['magento1_error'] }}
</div>
@endif
</div>
<div>
<strong>Magento 2:</strong>
<span class="status-badge {{ $connectionTest['magento2'] ? 'success' : 'error' }}">
{{ $connectionTest['magento2'] ? '✓ Connected' : '✗ Failed' }}
</span>
@if(!$connectionTest['magento2'] && isset($connectionTest['magento2_error']))
<div style="color: #721c24; margin-top: 5px; font-size: 0.9em;">
{{ $connectionTest['magento2_error'] }}
</div>
@endif
</div>
</div>
<button class="btn btn-secondary" onclick="testConnections()">Test Connections</button>
</div>
@endsection
{{-- CSS is loaded globally via app.css --}}
@push('scripts')
@vite(['resources/js/connections.js'])
<div data-test-route="{{ route('connections.test-connections') }}" style="display: none;"></div>
@endpush

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Magento Migration Tool</title>
@vite(['resources/css/app.css'])
@stack('styles')
</head>
<body>
<div class="container">
<div class="header">
<h1>🛒 Magento Migration</h1>
<p>Migrate categories from Magento 1 to Magento 2 with multi-store support</p>
</div>
<div class="content">
@include('partials.navigation')
@yield('content')
</div>
</div>
@stack('scripts')
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
<nav class="navigation">
<a href="{{ route('connections.index') }}" class="nav-link {{ request()->routeIs('connections.*') ? 'active' : '' }}">
Database Connections
</a>
<a href="{{ route('categories.index') }}" class="nav-link {{ request()->routeIs('categories.*') ? 'active' : '' }}">
Category Trees
</a>
<a href="{{ route('migration.index') }}" class="nav-link {{ request()->routeIs('migration.*') ? 'active' : '' }}">
Category Migration
</a>
<a href="{{ route('attributes.index') }}" class="nav-link {{ request()->routeIs('attributes.*') ? 'active' : '' }}">
Attribute List
</a>
<a href="{{ route('products.index') }}" class="nav-link {{ request()->routeIs('products.*') ? 'active' : '' }}">
Products
</a>
</nav>

View File

@ -0,0 +1,234 @@
@extends('layouts.app')
@section('content')
<!-- Product Migration Section -->
<div class="section">
<h2>🚀 Product Migration</h2>
<div class="info-box" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 10px; color: #1976D2; font-size: 1.1em;">What happens when you click "Start Product Migration"?</h3>
<p style="margin: 5px 0; color: #1976D2;">The product migration process will:</p>
<ul style="margin: 10px 0 0 20px; color: #1976D2; line-height: 1.8;">
<li><strong>Create new products:</strong> If a product with the same SKU doesn't exist in Magento 2, it will be created with all its attributes</li>
<li><strong>Update existing products:</strong> If a product with the same SKU already exists in Magento 2, it will be updated with the latest data from Magento 1</li>
<li><strong>Migrate product attributes:</strong> All product attributes including SKU, name, description, price, weight, status, visibility, and tax class will be migrated</li>
<li><strong>Assign to categories:</strong> Products will be assigned to the corresponding categories in Magento 2</li>
<li><strong>Generate logs:</strong> Detailed migration logs showing which products were added, updated, or encountered errors</li>
</ul>
<p style="margin: 10px 0 0 0; color: #d32f2f; font-weight: 600;">⚠️ <strong>Warning:</strong> This will modify your Magento 2 database. Make sure you have a backup before proceeding.</p>
</div>
<div style="margin-top: 20px; display: flex; gap: 15px; flex-wrap: wrap;">
<button id="dryRunProductMigrationBtn" class="btn btn-secondary" onclick="startProductMigration(true)">
Run Dry Run (Check for Errors)
</button>
<button id="startProductMigrationBtn" class="btn btn-primary" onclick="startProductMigration(false)">
Start Product Migration
</button>
<button id="deleteProductsAboveM1MaxBtn" class="btn btn-danger" onclick="deleteProductsAboveM1Max()">
Delete M2 Products Above M1 Max ID
</button>
</div>
</div>
<!-- Missing Attributes Section -->
<div class="section" id="missingAttributesSection" style="margin-top: 30px; display: none;">
<h2>⚠️ Missing Attributes</h2>
<p style="margin-bottom: 15px; color: #666;">The following attributes exist in Magento 1 but are missing in Magento 2. These need to be created before migration:</p>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 400px; overflow-y: auto;">
<table class="products-table" id="missingAttributesTable">
<thead>
<tr>
<th>Attribute Code</th>
<th>Label</th>
<th>Backend Type</th>
</tr>
</thead>
<tbody id="missingAttributesTableBody">
</tbody>
</table>
</div>
</div>
<!-- Migration Logs Section -->
<div class="section" style="margin-top: 30px;">
<h2>📋 Product Migration Logs</h2>
<p style="margin-bottom: 15px; color: #666;">Detailed logs showing which products were added, updated, or encountered errors during migration:</p>
<div class="log-container" id="productMigrationLogContainer" style="display: block;">
<div id="productMigrationLogContent">
<div class="log-entry" style="color: #999; font-style: italic;">
No product migration logs yet. Click "Run Dry Run" or "Start Product Migration" to begin.
</div>
</div>
</div>
</div>
<!-- Product Statistics -->
<div class="section" style="margin-top: 30px;">
<h2>📊 Product Statistics</h2>
<div class="stats">
<div class="stat-card">
<div class="number">{{ $m1Products->count() }}</div>
<div class="label">Magento 1 Products</div>
</div>
<div class="stat-card">
<div class="number">{{ $m2Products->count() }}</div>
<div class="label">Magento 2 Products</div>
</div>
<div class="stat-card">
<div class="number">{{ $m1ProductsNotInM2->count() }}</div>
<div class="label">M1 Products Not in M2</div>
</div>
<div class="stat-card">
<div class="number">{{ $m2ProductsNotInM1->count() }}</div>
<div class="label">M2 Products Not in M1</div>
</div>
</div>
</div>
<!-- Products Not in M2 Section -->
@if($m1ProductsNotInM2->count() > 0)
<div class="section" style="margin-top: 30px;">
<h2>⚠️ Magento 1 Products Not in Magento 2</h2>
<p>These products exist in Magento 1 but are missing in Magento 2:</p>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 400px; overflow-y: auto; margin-top: 15px;">
<table class="products-table">
<thead>
<tr>
<th>ID</th>
<th>SKU</th>
<th>Name</th>
<th>Type</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($m1ProductsNotInM2->take(50) as $product)
<tr>
<td>{{ $product->entity_id }}</td>
<td>{{ $product->sku ?? 'N/A' }}</td>
<td>{{ $product->name ?? 'Unnamed Product' }}</td>
<td>{{ $product->type_id ?? 'N/A' }}</td>
<td>
@if(($product->status ?? 0) == 1)
<span style="color: #28a745;">Enabled</span>
@else
<span style="color: #dc3545;">Disabled</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@if($m1ProductsNotInM2->count() > 50)
<p style="margin-top: 15px; color: #666;">Showing first 50 of {{ $m1ProductsNotInM2->count() }} products.</p>
@endif
</div>
</div>
@endif
<!-- Products Not in M1 Section -->
@if($m2ProductsNotInM1->count() > 0)
<div class="section" style="margin-top: 30px;">
<h2>⚠️ Magento 2 Products Not in Magento 1</h2>
<p>These products exist in Magento 2 but are missing in Magento 1:</p>
<div style="background: white; border: 1px solid #ddd; border-radius: 6px; padding: 20px; max-height: 400px; overflow-y: auto; margin-top: 15px;">
<table class="products-table">
<thead>
<tr>
<th>ID</th>
<th>SKU</th>
<th>Name</th>
<th>Type</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($m2ProductsNotInM1->take(50) as $product)
<tr>
<td>{{ $product->entity_id }}</td>
<td>{{ $product->sku ?? 'N/A' }}</td>
<td>{{ $product->name ?? 'Unnamed Product' }}</td>
<td>{{ $product->type_id ?? 'N/A' }}</td>
<td>
@if(($product->status ?? 0) == 1)
<span style="color: #28a745;">Enabled</span>
@else
<span style="color: #dc3545;">Disabled</span>
@endif
</td>
<td>
<button
class="btn btn-danger"
onclick="deleteM2Product({{ $product->entity_id }}, '{{ addslashes($product->name ?? 'Unnamed Product') }}', '{{ addslashes($product->sku ?? 'N/A') }}')"
style="padding: 6px 12px; font-size: 0.85em;">
Delete
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
@if($m2ProductsNotInM1->count() > 50)
<p style="margin-top: 15px; color: #666;">Showing first 50 of {{ $m2ProductsNotInM1->count() }} products.</p>
@endif
</div>
</div>
@endif
<!-- Sync Product Categories Section -->
<div class="section" style="margin-top: 30px;">
<h2>🔄 Sync Product Categories</h2>
<p>Sync product category assignments from Magento 1 to Magento 2:</p>
<button id="syncProductCategoriesBtn" class="btn btn-primary" onclick="syncProductCategories()" style="margin-top: 15px;">
Sync Product Categories
</button>
</div>
<!-- Category Tree with Products Section -->
<div class="section" style="margin-top: 30px;">
<h2>🌳 Category Tree with Products</h2>
<p>View all categories with their associated products in a tree structure</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
<!-- Magento 1 Tree with Products -->
<div>
<h3 style="margin-bottom: 15px; color: #667eea;">Magento 1 Categories</h3>
<button onclick="loadM1CategoryTreeWithProducts()" style="padding: 10px 20px; font-size: 1em; background: #667eea; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; margin-bottom: 15px;">
Load M1 Category Tree with Products
</button>
<div class="tree-container" id="m1-category-products-tree-container">
<div class="tree-loading">Click the button above to load categories with products</div>
</div>
</div>
<!-- Magento 2 Tree with Products -->
<div>
<h3 style="margin-bottom: 15px; color: #764ba2;">Magento 2 Categories</h3>
<button onclick="loadM2CategoryTreeWithProducts()" style="padding: 10px 20px; font-size: 1em; background: #764ba2; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; margin-bottom: 15px;">
Load M2 Category Tree with Products
</button>
<div class="tree-container" id="m2-category-products-tree-container">
<div class="tree-loading">Click the button above to load categories with products</div>
</div>
</div>
</div>
</div>
@endsection
{{-- CSS is loaded globally via app.css --}}
@push('scripts')
@vite(['resources/js/products.js'])
<script>
window.productRoutes = {
migrateProducts: '{{ route("products.migrate-products") }}',
deleteProduct: '{{ route("products.delete-product", ["productId" => ":id"]) }}',
deleteProductsAboveM1Max: '{{ route("products.delete-products-above-m1-max") }}',
syncProductCategories: '{{ route("products.sync-product-categories") }}',
magento1CategoryTreeWithProducts: '{{ route("categories.magento1-category-tree-with-products") }}',
magento2CategoryTreeWithProducts: '{{ route("categories.magento2-category-tree-with-products") }}'
};
</script>
@endpush

View File

@ -1,28 +1,53 @@
<?php <?php
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\MagentoMigrationController; use App\Http\Controllers\ConnectionsController;
use App\Http\Controllers\CategoriesController;
use App\Http\Controllers\MigrationController;
use App\Http\Controllers\AttributesController;
use App\Http\Controllers\ProductsController;
Route::get('/', function () { Route::get('/', function () {
return redirect('/migration'); return redirect('/connections');
}); });
Route::prefix('migration')->group(function () { // Connections routes
Route::get('/', [MagentoMigrationController::class, 'index'])->name('migration.index'); Route::prefix('connections')->name('connections.')->group(function () {
Route::post('/migrate', [MagentoMigrationController::class, 'migrate'])->name('migration.migrate'); Route::get('/', [ConnectionsController::class, 'index'])->name('index');
Route::get('/test-connections', [MagentoMigrationController::class, 'testConnections'])->name('migration.test-connections'); Route::get('/test', [ConnectionsController::class, 'testConnections'])->name('test-connections');
Route::get('/magento1-categories', [MagentoMigrationController::class, 'getMagento1Categories'])->name('migration.magento1-categories'); });
Route::get('/magento1-category-tree', [MagentoMigrationController::class, 'getMagento1CategoryTree'])->name('migration.magento1-category-tree');
Route::get('/magento1-category-tree-with-products', [MagentoMigrationController::class, 'getMagento1CategoryTreeWithProducts'])->name('migration.magento1-category-tree-with-products'); // Categories routes
Route::get('/magento2-category-tree', [MagentoMigrationController::class, 'getMagento2CategoryTree'])->name('migration.magento2-category-tree'); Route::prefix('categories')->name('categories.')->group(function () {
Route::get('/magento2-category-tree-with-products', [MagentoMigrationController::class, 'getMagento2CategoryTreeWithProducts'])->name('migration.magento2-category-tree-with-products'); Route::get('/', [CategoriesController::class, 'index'])->name('index');
Route::get('/m2-categories-not-in-m1', [MagentoMigrationController::class, 'getM2CategoriesNotInM1'])->name('migration.m2-categories-not-in-m1'); Route::get('/magento1', [CategoriesController::class, 'getMagento1Categories'])->name('magento1-categories');
Route::delete('/category/{categoryId}', [MagentoMigrationController::class, 'deleteCategory'])->name('migration.delete-category'); Route::get('/magento1-tree', [CategoriesController::class, 'getMagento1CategoryTree'])->name('magento1-category-tree');
Route::put('/category/{categoryId}/rename', [MagentoMigrationController::class, 'renameCategory'])->name('migration.rename-category'); Route::get('/magento1-tree-with-products', [CategoriesController::class, 'getMagento1CategoryTreeWithProducts'])->name('magento1-category-tree-with-products');
Route::post('/attribute/{attributeId}/migrate', [MagentoMigrationController::class, 'migrateAttribute'])->name('migration.migrate-attribute'); Route::get('/magento2-tree', [CategoriesController::class, 'getMagento2CategoryTree'])->name('magento2-category-tree');
Route::post('/attribute-group/{groupId}/{setId}/migrate', [MagentoMigrationController::class, 'migrateAttributeGroup'])->name('migration.migrate-attribute-group'); Route::get('/magento2-tree-with-products', [CategoriesController::class, 'getMagento2CategoryTreeWithProducts'])->name('magento2-category-tree-with-products');
Route::post('/products/migrate', [MagentoMigrationController::class, 'migrateProducts'])->name('migration.migrate-products'); Route::get('/m2-not-in-m1', [CategoriesController::class, 'getM2CategoriesNotInM1'])->name('m2-categories-not-in-m1');
Route::post('/products/sync-categories', [MagentoMigrationController::class, 'syncProductCategories'])->name('migration.sync-product-categories'); Route::delete('/{categoryId}', [CategoriesController::class, 'deleteCategory'])->name('delete-category');
Route::delete('/products/{productId}', [MagentoMigrationController::class, 'deleteM2Product'])->name('migration.delete-product'); Route::put('/{categoryId}/rename', [CategoriesController::class, 'renameCategory'])->name('rename-category');
Route::delete('/products/above-m1-max', [MagentoMigrationController::class, 'deleteM2ProductsAboveM1Max'])->name('migration.delete-products-above-m1-max'); });
// Migration routes
Route::prefix('migration')->name('migration.')->group(function () {
Route::get('/', [MigrationController::class, 'index'])->name('index');
Route::post('/migrate', [MigrationController::class, 'migrate'])->name('migrate');
});
// Attributes routes
Route::prefix('attributes')->name('attributes.')->group(function () {
Route::get('/', [AttributesController::class, 'index'])->name('index');
Route::post('/{attributeId}/migrate', [AttributesController::class, 'migrateAttribute'])->name('migrate-attribute');
Route::post('/group/{groupId}/{setId}/migrate', [AttributesController::class, 'migrateAttributeGroup'])->name('migrate-attribute-group');
});
// Products routes
Route::prefix('products')->name('products.')->group(function () {
Route::get('/', [ProductsController::class, 'index'])->name('index');
Route::post('/migrate', [ProductsController::class, 'migrateProducts'])->name('migrate-products');
Route::post('/sync-categories', [ProductsController::class, 'syncProductCategories'])->name('sync-product-categories');
Route::delete('/{productId}', [ProductsController::class, 'deleteM2Product'])->name('delete-product');
Route::delete('/above-m1-max', [ProductsController::class, 'deleteM2ProductsAboveM1Max'])->name('delete-products-above-m1-max');
}); });

View File

@ -5,7 +5,15 @@ import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.js'], input: [
'resources/css/app.css',
'resources/js/app.js',
'resources/js/connections.js',
'resources/js/categories.js',
'resources/js/migration.js',
'resources/js/attributes.js',
'resources/js/products.js',
],
refresh: true, refresh: true,
}), }),
tailwindcss(), tailwindcss(),