1use dioxus::prelude::*;
4#[allow(unused_imports)]
5use dioxus_router::prelude::*;
6
7use crate::ui::{
8 router::{nav, Route},
9 state::auth::use_has_permission,
10};
11
12#[derive(Props, Clone, PartialEq)]
14pub struct SidebarProps {
15 pub collapsed: bool,
17 pub mobile_open: bool,
19 pub on_close: Callback<Event<MouseData>>,
21}
22
23#[derive(Debug, Clone, PartialEq)]
25pub struct NavItem {
26 pub id: String,
27 pub label: String,
28 pub icon: String,
29 pub route: Option<Route>,
30 pub children: Vec<NavItem>,
31 pub required_permission: Option<(String, String)>, pub badge: Option<String>,
33 pub external_url: Option<String>,
34}
35
36#[component]
38pub fn Sidebar(props: SidebarProps) -> Element {
39 let current_route = use_route::<Route>();
41 let has_permission = use_has_permission();
42
43 let nav_items = get_navigation_items();
45
46 let filtered_nav_items = nav_items
48 .into_iter()
49 .filter(|item| {
50 if let Some((resource, action)) = &item.required_permission {
51 has_permission(resource, action)
52 } else {
53 true
54 }
55 })
56 .collect::<Vec<_>>();
57
58 rsx! {
59 div {
61 class: format!(
62 "hidden lg:flex lg:flex-col lg:fixed lg:inset-y-0 lg:z-40 lg:transition-all lg:duration-200 lg:ease-in-out {}",
63 if props.collapsed { "lg:w-16" } else { "lg:w-64" }
64 ),
65 div {
66 class: "flex flex-col flex-grow bg-white border-r border-gray-200 pt-16 pb-4 overflow-y-auto",
67 nav {
68 class: "flex-1 px-2 space-y-1",
69 for item in &filtered_nav_items {
70 NavigationItem {
71 key: "{item.id}",
72 item: item.clone(),
73 collapsed: props.collapsed,
74 current_route: current_route.clone()
75 }
76 }
77 }
78
79 if !props.collapsed {
81 div {
82 class: "px-2 mt-6",
83 div {
84 class: "text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2",
85 "Plugins"
86 }
87 PluginNavigation {}
88 }
89 }
90 }
91 }
92
93 if props.mobile_open {
95 div {
96 class: "lg:hidden fixed inset-0 z-50 flex",
97
98 div {
100 class: "relative flex flex-col flex-1 w-64 bg-white",
101
102 div {
104 class: "absolute top-0 right-0 -mr-12 pt-2",
105 button {
106 r#type: "button",
107 class: "ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white",
108 onclick: move |e| props.on_close.call(e),
109 span {
110 class: "sr-only",
111 "Close sidebar"
112 }
113 svg {
115 class: "h-6 w-6 text-white",
116 xmlns: "http://www.w3.org/2000/svg",
117 fill: "none",
118 view_box: "0 0 24 24",
119 stroke: "currentColor",
120 path {
121 stroke_linecap: "round",
122 stroke_linejoin: "round",
123 stroke_width: "2",
124 d: "M6 18L18 6M6 6l12 12"
125 }
126 }
127 }
128 }
129
130 div {
132 class: "flex-1 h-0 pt-5 pb-4 overflow-y-auto",
133 nav {
134 class: "px-2 space-y-1",
135 for item in &filtered_nav_items {
136 NavigationItem {
137 key: "{item.id}",
138 item: item.clone(),
139 collapsed: false,
140 current_route: current_route.clone(),
141 on_click: Some(props.on_close)
142 }
143 }
144 }
145
146 div {
148 class: "px-2 mt-6",
149 div {
150 class: "text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2",
151 "Plugins"
152 }
153 PluginNavigation {
154 on_click: Some(props.on_close)
155 }
156 }
157 }
158 }
159 }
160 }
161 }
162}
163
164#[component]
166fn NavigationItem(
167 item: NavItem,
168 collapsed: bool,
169 current_route: Route,
170 #[props(default = None)] on_click: Option<Callback<Event<MouseData>>>,
171) -> Element {
172 let is_active = item
173 .route
174 .as_ref()
175 .map(|route| nav::is_active_route(¤t_route, route))
176 .unwrap_or(false);
177
178 if !item.children.is_empty() {
180 let mut expanded = use_signal(|| false);
181
182 rsx! {
183 div {
184 button {
186 r#type: "button",
187 class: format!(
188 "group w-full flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900 {}",
189 if collapsed { "justify-center" } else { "justify-between" }
190 ),
191 onclick: move |_| {
192 if !collapsed {
193 expanded.set(!expanded());
194 }
195 },
196
197 div {
198 class: "flex items-center",
199 span {
200 class: "text-lg mr-3",
201 "{item.icon}"
202 }
203 if !collapsed {
204 span { "{item.label}" }
205 }
206 }
207
208 if !collapsed && !item.children.is_empty() {
209 svg {
211 class: format!(
212 "h-4 w-4 transition-transform {}",
213 if expanded() { "transform rotate-90" } else { "" }
214 ),
215 xmlns: "http://www.w3.org/2000/svg",
216 view_box: "0 0 20 20",
217 fill: "currentColor",
218 path {
219 fill_rule: "evenodd",
220 d: "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 111.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z",
221 clip_rule: "evenodd"
222 }
223 }
224 }
225 }
226
227 if !collapsed && expanded() {
229 div {
230 class: "ml-6 space-y-1",
231 for child in &item.children {
232 NavigationItem {
233 key: "{child.id}",
234 item: child.clone(),
235 collapsed: false,
236 current_route: current_route.clone(),
237 on_click: on_click
238 }
239 }
240 }
241 }
242 }
243 }
244 } else {
245 rsx! {
247 if let Some(route) = &item.route {
248 Link {
249 to: route.clone(),
250 class: format!(
251 "group flex items-center px-2 py-2 text-sm font-medium rounded-md {} {}",
252 if is_active {
253 "bg-blue-50 border-r-4 border-blue-600 text-blue-700"
254 } else {
255 "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
256 },
257 if collapsed { "justify-center" } else { "" }
258 ),
259 onclick: move |e| {
260 if let Some(callback) = &on_click {
261 callback.call(e);
262 }
263 },
264 span {
265 class: format!(
266 "text-lg {}",
267 if collapsed { "" } else { "mr-3" }
268 ),
269 "{item.icon}"
270 }
271 if !collapsed {
272 span {
273 class: "flex-1",
274 "{item.label}"
275 }
276 if let Some(badge) = &item.badge {
277 span {
278 class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800",
279 "{badge}"
280 }
281 }
282 }
283 }
284 } else if let Some(url) = &item.external_url {
285 a {
286 href: "{url}",
287 target: "_blank",
288 rel: "noopener noreferrer",
289 class: format!(
290 "group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900 {}",
291 if collapsed { "justify-center" } else { "" }
292 ),
293 span {
294 class: format!(
295 "text-lg {}",
296 if collapsed { "" } else { "mr-3" }
297 ),
298 "{item.icon}"
299 }
300 if !collapsed {
301 span {
302 class: "flex-1",
303 "{item.label}"
304 }
305 svg {
307 class: "h-4 w-4 text-gray-400",
308 xmlns: "http://www.w3.org/2000/svg",
309 fill: "none",
310 view_box: "0 0 24 24",
311 stroke: "currentColor",
312 path {
313 stroke_linecap: "round",
314 stroke_linejoin: "round",
315 stroke_width: "2",
316 d: "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
317 }
318 }
319 }
320 }
321 }
322 }
323 }
324}
325
326#[component]
328fn PluginNavigation(
329 #[props(default = None)] on_click: Option<Callback<Event<MouseData>>>,
330) -> Element {
331 let plugins = vec![
333 ("inventory", "📦", "Inventory"),
334 ("sales", "💰", "Sales"),
335 ("reports", "📊", "Reports"),
336 ("users", "👥", "User Management"),
337 ];
338
339 rsx! {
340 div {
341 class: "space-y-1",
342 for (plugin_id, icon, label) in plugins {
343 Link {
344 key: "{plugin_id}",
345 to: Route::Plugin { plugin_id: plugin_id.to_string() },
346 class: "group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900",
347 onclick: move |e| {
348 if let Some(callback) = &on_click {
349 callback.call(e);
350 }
351 },
352 span {
353 class: "text-lg mr-3",
354 "{icon}"
355 }
356 "{label}"
357 }
358 }
359 }
360 }
361}
362
363fn get_navigation_items() -> Vec<NavItem> {
365 vec![
366 NavItem {
367 id: "dashboard".to_string(),
368 label: "Dashboard".to_string(),
369 icon: "📊".to_string(),
370 route: Some(Route::Dashboard {}),
371 children: vec![],
372 required_permission: None,
373 badge: None,
374 external_url: None,
375 },
376 NavItem {
377 id: "profile".to_string(),
378 label: "Profile".to_string(),
379 icon: "👤".to_string(),
380 route: Some(Route::Profile {}),
381 children: vec![],
382 required_permission: None,
383 badge: None,
384 external_url: None,
385 },
386 NavItem {
387 id: "plugins".to_string(),
388 label: "Plugins".to_string(),
389 icon: "🧩".to_string(),
390 route: Some(Route::Plugins {}),
391 children: vec![],
392 required_permission: Some(("plugins".to_string(), "read".to_string())),
393 badge: None,
394 external_url: None,
395 },
396 NavItem {
397 id: "settings".to_string(),
398 label: "Settings".to_string(),
399 icon: "⚙️".to_string(),
400 route: Some(Route::Settings {}),
401 children: vec![],
402 required_permission: Some(("settings".to_string(), "read".to_string())),
403 badge: None,
404 external_url: None,
405 },
406 NavItem {
407 id: "admin".to_string(),
408 label: "Administration".to_string(),
409 icon: "👑".to_string(),
410 route: Some(Route::Admin {}),
411 children: vec![],
412 required_permission: Some(("admin".to_string(), "read".to_string())),
413 badge: Some("Admin".to_string()),
414 external_url: None,
415 },
416 NavItem {
417 id: "help".to_string(),
418 label: "Help & Support".to_string(),
419 icon: "❓".to_string(),
420 route: None,
421 children: vec![
422 NavItem {
423 id: "documentation".to_string(),
424 label: "Documentation".to_string(),
425 icon: "📚".to_string(),
426 route: None,
427 children: vec![],
428 required_permission: None,
429 badge: None,
430 external_url: Some("https://docs.qorzen.com".to_string()),
431 },
432 NavItem {
433 id: "support".to_string(),
434 label: "Contact Support".to_string(),
435 icon: "💬".to_string(),
436 route: None,
437 children: vec![],
438 required_permission: None,
439 badge: None,
440 external_url: Some("mailto:support@qorzen.com".to_string()),
441 },
442 ],
443 required_permission: None,
444 badge: None,
445 external_url: None,
446 },
447 ]
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn test_navigation_items() {
456 let items = get_navigation_items();
457 assert!(!items.is_empty());
458
459 let dashboard = items.iter().find(|item| item.id == "dashboard");
461 assert!(dashboard.is_some());
462 assert_eq!(dashboard.unwrap().label, "Dashboard");
463 }
464
465 #[test]
466 fn test_nav_item_with_children() {
467 let items = get_navigation_items();
468 let help_item = items.iter().find(|item| item.id == "help");
469 assert!(help_item.is_some());
470 assert!(!help_item.unwrap().children.is_empty());
471 }
472
473 #[test]
474 fn test_sidebar_component_creation() {
475 let on_close = Callback::new(|_| {});
476
477 let _sidebar = rsx! {
478 Sidebar {
479 collapsed: false,
480 mobile_open: false,
481 on_close: on_close
482 }
483 };
484 }
485}