Skill para implementar sistema de menús dinámicos y templates de menú
Este skill genera un módulo completo de Menús Dinámicos con soporte para:
| Variante | Descripción |
|---|---|
| basic | Menú dinámico simple desde BD |
| full | Templates de menú + asignación a tenants + administración visual |
proyecto/
├── backend/
│ ├── internal/
│ │ ├── database/
│ │ │ └── queries/
│ │ │ └── menu.sql
│ │ └── handlers/
│ │ └── menu_handler.go
│ └── migrations/
│ ├── 000012_create_menu_items.up.sql
│ └── 000020_create_menu_templates.up.sql
└── frontend/
└── lib/
├── core/
│ ├── models/
│ │ └── menu_module.dart
│ └── providers/
│ └── menu_provider.dart
└── features/
└── configuration/
└── presentation/
└── widgets/
├── menu_admin_tab.dart
└── menu_template_dialog.dart
CREATE TABLE auth.menu_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Jerarquía
parent_id UUID REFERENCES auth.menu_items(id) ON DELETE CASCADE,
-- Contenido
title VARCHAR(100) NOT NULL,
icon VARCHAR(100),
route VARCHAR(255),
-- Permisos requeridos (JSON array o string)
required_permission VARCHAR(100),
-- Orden y estado
display_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT true,
-- Si es divider o header
item_type VARCHAR(20) DEFAULT 'item', -- 'item', 'divider', 'header'
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_menu_items_parent ON auth.menu_items(parent_id);
CREATE INDEX idx_menu_items_order ON auth.menu_items(display_order);
CREATE INDEX idx_menu_items_active ON auth.menu_items(is_active);
CREATE TABLE auth.menu_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Relación N:N entre templates y menu_items
CREATE TABLE auth.menu_template_items (
template_id UUID REFERENCES auth.menu_templates(id) ON DELETE CASCADE,
menu_item_id UUID REFERENCES auth.menu_items(id) ON DELETE CASCADE,
display_order INT DEFAULT 0,
PRIMARY KEY (template_id, menu_item_id)
);
-- Asignación de template a tenant
ALTER TABLE public.tenants
ADD COLUMN menu_template_id UUID REFERENCES auth.menu_templates(id);
-- Insertar items base del menú
INSERT INTO auth.menu_items (id, parent_id, title, icon, route, required_permission, display_order) VALUES
-- Dashboard
(gen_random_uuid(), NULL, 'Dashboard', 'dashboard', '/dashboard', 'dashboard:view', 10),
-- Sección de Configuración
(gen_random_uuid(), NULL, 'Configuración', 'settings', NULL, 'settings:read', 100),
-- Sub-items serían insertados con parent_id apuntando al de Configuración
-- Más items...
ON CONFLICT DO NOTHING;
-- Template por defecto
INSERT INTO auth.menu_templates (id, name, description, is_default) VALUES
(gen_random_uuid(), 'Standard', 'Menú estándar para todos los tenants', true);
-- ==================== Menu Items ====================
-- name: ListMenuItems :many
SELECT * FROM auth.menu_items
WHERE is_active = true
ORDER BY parent_id NULLS FIRST, display_order;
-- name: ListAllMenuItems :many
SELECT * FROM auth.menu_items
ORDER BY parent_id NULLS FIRST, display_order;
-- name: GetMenuItemByID :one
SELECT * FROM auth.menu_items WHERE id = $1;
-- name: CreateMenuItem :one
INSERT INTO auth.menu_items (
parent_id, title, icon, route,
required_permission, display_order, is_active, item_type
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: UpdateMenuItem :one
UPDATE auth.menu_items
SET parent_id = $2,
title = $3,
icon = $4,
route = $5,
required_permission = $6,
display_order = $7,
is_active = $8,
item_type = $9,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *;
-- name: ToggleMenuItem :one
UPDATE auth.menu_items
SET is_active = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *;
-- name: DeleteMenuItem :exec
DELETE FROM auth.menu_items WHERE id = $1;
-- name: ReorderMenuItem :exec
UPDATE auth.menu_items
SET display_order = $2
WHERE id = $1;
-- name: GetMenuItemChildren :many
SELECT * FROM auth.menu_items
WHERE parent_id = $1
ORDER BY display_order;
-- ==================== Menu Templates ====================
-- name: ListMenuTemplates :many
SELECT t.*,
(SELECT COUNT(*) FROM auth.menu_template_items mti WHERE mti.template_id = t.id) as item_count,
(SELECT COUNT(*) FROM public.tenants te WHERE te.menu_template_id = t.id) as tenant_count
FROM auth.menu_templates t
ORDER BY t.name;
-- name: GetMenuTemplateByID :one
SELECT * FROM auth.menu_templates WHERE id = $1;
-- name: CreateMenuTemplate :one
INSERT INTO auth.menu_templates (name, description, is_default)
VALUES ($1, $2, $3)
RETURNING *;
-- name: UpdateMenuTemplate :one
UPDATE auth.menu_templates
SET name = $2,
description = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *;
-- name: DeleteMenuTemplate :exec
DELETE FROM auth.menu_templates
WHERE id = $1 AND is_default = false;
-- name: SetDefaultTemplate :exec
UPDATE auth.menu_templates
SET is_default = (id = $1);
-- name: GetTemplateItems :many
SELECT mi.*, mti.display_order as template_order
FROM auth.menu_items mi
INNER JOIN auth.menu_template_items mti ON mi.id = mti.menu_item_id
WHERE mti.template_id = $1
ORDER BY mi.parent_id NULLS FIRST, mti.display_order;
-- name: AddItemToTemplate :exec
INSERT INTO auth.menu_template_items (template_id, menu_item_id, display_order)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING;
-- name: RemoveItemFromTemplate :exec
DELETE FROM auth.menu_template_items
WHERE template_id = $1 AND menu_item_id = $2;
-- name: SyncTemplateItems :exec
DELETE FROM auth.menu_template_items WHERE template_id = $1;
-- name: GetTenantMenu :many
-- Obtiene el menú para un tenant específico basado en su template
SELECT mi.*
FROM auth.menu_items mi
WHERE mi.is_active = true
AND (
-- Si el tenant tiene template, usar sus items
EXISTS (
SELECT 1 FROM auth.menu_template_items mti
WHERE mti.menu_item_id = mi.id
AND mti.template_id = (
SELECT menu_template_id FROM public.tenants WHERE id = $1
)
)
-- Si no tiene template, usar el default
OR (
(SELECT menu_template_id FROM public.tenants WHERE id = $1) IS NULL
AND EXISTS (
SELECT 1 FROM auth.menu_template_items mti
INNER JOIN auth.menu_templates mt ON mti.template_id = mt.id
WHERE mti.menu_item_id = mi.id AND mt.is_default = true
)
)
)
ORDER BY mi.parent_id NULLS FIRST, mi.display_order;
-- name: AssignTemplateToTenant :exec
UPDATE public.tenants
SET menu_template_id = $2
WHERE id = $1;
| Método | Ruta | Descripción |
|---|---|---|
| Menú del Usuario | ||
| GET | /menu | Obtener menú dinámico del usuario |
| Administración de Items | ||
| GET | /menu/items | Listar todos los items |
| GET | /menu/items/:id | Obtener item por ID |
| POST | /menu/items | Crear item |
| PUT | /menu/items/:id | Actualizar item |
| PATCH | /menu/items/:id/toggle | Activar/desactivar |
| DELETE | /menu/items/:id | Eliminar item |
| PUT | /menu/items/reorder | Reordenar items |
| Templates (Variante full) | ||
| GET | /menu/templates | Listar templates |
| POST | /menu/templates | Crear template |
| PUT | /menu/templates/:id | Actualizar template |
| DELETE | /menu/templates/:id | Eliminar template |
| GET | /menu/templates/:id/items | Items del template |
| PUT | /menu/templates/:id/items | Sincronizar items del template |
| GET | /menu/templates/:id/tenants | Tenants con este template |
| POST | /menu/templates/:id/tenants | Asignar template a tenants |
type MenuResponse struct {
Items []MenuItem `json:"items"`
}
type MenuItem struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Route string `json:"route,omitempty"`
RequiredPermission string `json:"required_permission,omitempty"`
ItemType string `json:"item_type"`
Children []MenuItem `json:"children,omitempty"`
}
func (h *MenuHandler) GetMenu(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
userPermissions := middleware.GetUserPermissions(c)
// Obtener items del menú para el tenant
items, err := h.queries.GetTenantMenu(c, toPgUUID(tenantID))
if err != nil {
c.JSON(500, gin.H{"error": "failed to get menu"})
return
}
// Construir árbol y filtrar por permisos
tree := h.buildMenuTree(items, userPermissions)
c.JSON(200, MenuResponse{Items: tree})
}
func (h *MenuHandler) buildMenuTree(items []database.AuthMenuItem, permissions map[string]bool) []MenuItem {
// 1. Filtrar items por permisos
// 2. Construir mapa de parent -> children
// 3. Recursivamente construir árbol
// ...
}
/// Módulo/Item de menú
class MenuModule {
final String id;
final String? parentId;
final String title;
final String? icon;
final String? route;
final String? requiredPermission;
final int displayOrder;
final bool isActive;
final String itemType; // 'item', 'divider', 'header'
final List<MenuModule> children;
MenuModule({
required this.id,
this.parentId,
required this.title,
this.icon,
this.route,
this.requiredPermission,
this.displayOrder = 0,
this.isActive = true,
this.itemType = 'item',
this.children = const [],
});
/// Es un item con ruta navegable
bool get isNavigable => route != null && route!.isNotEmpty;
/// Es un grupo/contenedor
bool get isGroup => children.isNotEmpty;
/// Es un divider
bool get isDivider => itemType == 'divider';
/// Es un header
bool get isHeader => itemType == 'header';
/// Ícono como IconData
IconData get iconData {
return _iconMap[icon] ?? Icons.circle;
}
static final Map<String, IconData> _iconMap = {
'dashboard': Icons.dashboard,
'settings': Icons.settings,
'people': Icons.people,
'business': Icons.business,
'security': Icons.security,
'menu': Icons.menu,
'palette': Icons.palette,
'storage': Icons.storage,
// Agregar más según necesidad
};
factory MenuModule.fromJson(Map<String, dynamic> json) {
return MenuModule(
id: json['id'],
parentId: json['parent_id'],
title: json['title'],
icon: json['icon'],
route: json['route'],
requiredPermission: json['required_permission'],
displayOrder: json['display_order'] ?? 0,
isActive: json['is_active'] ?? true,
itemType: json['item_type'] ?? 'item',
children: (json['children'] as List<dynamic>?)
?.map((c) => MenuModule.fromJson(c))
.toList() ?? [],
);
}
Map<String, dynamic> toJson() => {
'id': id,
'parent_id': parentId,
'title': title,
'icon': icon,
'route': route,
'required_permission': requiredPermission,
'display_order': displayOrder,
'is_active': isActive,
'item_type': itemType,
};
}
/// Template de menú
class MenuTemplate {
final String id;
final String name;
final String? description;
final bool isDefault;
final int itemCount;
final int tenantCount;
MenuTemplate({
required this.id,
required this.name,
this.description,
this.isDefault = false,
this.itemCount = 0,
this.tenantCount = 0,
});
factory MenuTemplate.fromJson(Map<String, dynamic> json) => MenuTemplate(
id: json['id'],
name: json['name'],
description: json['description'],
isDefault: json['is_default'] ?? false,
itemCount: json['item_count'] ?? 0,
tenantCount: json['tenant_count'] ?? 0,
);
}
class MenuProvider extends ChangeNotifier {
final MenuService _menuService;
List<MenuModule> _menuItems = [];
bool _isLoading = false;
String? _error;
List<MenuModule> get menuItems => _menuItems;
bool get isLoading => _isLoading;
String? get error => _error;
MenuProvider({required MenuService menuService}) : _menuService = menuService;
/// Cargar menú del usuario actual
Future<void> loadMenu() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_menuItems = await _menuService.getUserMenu();
_error = null;
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Refrescar menú
Future<void> refresh() => loadMenu();
/// Limpiar menú (al cerrar sesión)
void clear() {
_menuItems = [];
notifyListeners();
}
/// Buscar item por ruta
MenuModule? findByRoute(String route) {
return _findInTree(_menuItems, route);
}
MenuModule? _findInTree(List<MenuModule> items, String route) {
for (final item in items) {
if (item.route == route) return item;
if (item.children.isNotEmpty) {
final found = _findInTree(item.children, route);
if (found != null) return found;
}
}
return null;
}
}
Definir mapa de íconos soportados:
final Map<String, IconData> availableIcons = {
// Navegación
'dashboard': Icons.dashboard,
'home': Icons.home,
'menu': Icons.menu,
// Configuración
'settings': Icons.settings,
'tune': Icons.tune,
'build': Icons.build,
// Usuarios/Seguridad
'people': Icons.people,
'person': Icons.person,
'security': Icons.security,
'shield': Icons.shield,
'lock': Icons.lock,
// Negocios
'business': Icons.business,
'store': Icons.store,
'work': Icons.work,
// Datos
'storage': Icons.storage,
'folder': Icons.folder,
'description': Icons.description,
// Otros
'notifications': Icons.notifications,
'email': Icons.email,
'help': Icons.help,
'info': Icons.info,
};
// En sidebar_widget.dart
class Sidebar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<MenuProvider>(
builder: (context, menuProvider, child) {
if (menuProvider.isLoading) {
return const CircularProgressIndicator();
}
return ListView(
children: menuProvider.menuItems
.map((item) => _buildMenuItem(context, item))
.toList(),
);
},
);
}
Widget _buildMenuItem(BuildContext context, MenuModule item) {
if (item.isDivider) {
return const Divider();
}
if (item.isHeader) {
return ListTile(
title: Text(item.title, style: TextStyle(fontWeight: FontWeight.bold)),
dense: true,
);
}
if (item.isGroup) {
return ExpansionTile(
leading: Icon(item.iconData),
title: Text(item.title),
children: item.children
.map((child) => _buildMenuItem(context, child))
.toList(),
);
}
return ListTile(
leading: Icon(item.iconData),
title: Text(item.title),
onTap: () => Navigator.pushNamed(context, item.route!),
);
}
}