armature_admin/
ui.rs

1//! UI components for admin dashboard
2
3use crate::config::Theme;
4use serde::{Deserialize, Serialize};
5
6/// Pagination info
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Pagination {
9    /// Current page (1-indexed)
10    pub page: usize,
11    /// Total pages
12    pub total_pages: usize,
13    /// Items per page
14    pub per_page: usize,
15    /// Total items
16    pub total_items: usize,
17    /// Has previous page
18    pub has_prev: bool,
19    /// Has next page
20    pub has_next: bool,
21    /// Start item number (for display)
22    pub start_item: usize,
23    /// End item number (for display)
24    pub end_item: usize,
25}
26
27impl Pagination {
28    /// Create pagination info
29    pub fn new(page: usize, per_page: usize, total_items: usize) -> Self {
30        let total_pages = (total_items + per_page - 1) / per_page;
31        let page = page.min(total_pages).max(1);
32        let start_item = (page - 1) * per_page + 1;
33        let end_item = (start_item + per_page - 1).min(total_items);
34
35        Self {
36            page,
37            total_pages,
38            per_page,
39            total_items,
40            has_prev: page > 1,
41            has_next: page < total_pages,
42            start_item: if total_items > 0 { start_item } else { 0 },
43            end_item,
44        }
45    }
46
47    /// Get page numbers for pagination UI
48    pub fn page_numbers(&self, window: usize) -> Vec<PageNumber> {
49        let mut pages = Vec::new();
50
51        if self.total_pages <= 0 {
52            return pages;
53        }
54
55        // Always show first page
56        pages.push(PageNumber::Page(1));
57
58        let start = (self.page as i64 - window as i64).max(2) as usize;
59        let end = (self.page + window).min(self.total_pages - 1);
60
61        // Add ellipsis if needed
62        if start > 2 {
63            pages.push(PageNumber::Ellipsis);
64        }
65
66        // Add middle pages
67        for p in start..=end {
68            pages.push(PageNumber::Page(p));
69        }
70
71        // Add ellipsis before last page if needed
72        if end < self.total_pages - 1 {
73            pages.push(PageNumber::Ellipsis);
74        }
75
76        // Always show last page (if more than 1 page)
77        if self.total_pages > 1 {
78            pages.push(PageNumber::Page(self.total_pages));
79        }
80
81        pages
82    }
83}
84
85/// Page number for pagination
86#[derive(Debug, Clone, Copy)]
87pub enum PageNumber {
88    /// A specific page
89    Page(usize),
90    /// Ellipsis (...)
91    Ellipsis,
92}
93
94/// Flash message
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct FlashMessage {
97    /// Message type
98    pub level: MessageLevel,
99    /// Message content
100    pub message: String,
101    /// Auto-dismiss after seconds
102    pub dismiss_after: Option<u32>,
103}
104
105impl FlashMessage {
106    /// Create a success message
107    pub fn success(message: impl Into<String>) -> Self {
108        Self {
109            level: MessageLevel::Success,
110            message: message.into(),
111            dismiss_after: Some(5),
112        }
113    }
114
115    /// Create an error message
116    pub fn error(message: impl Into<String>) -> Self {
117        Self {
118            level: MessageLevel::Error,
119            message: message.into(),
120            dismiss_after: None,
121        }
122    }
123
124    /// Create a warning message
125    pub fn warning(message: impl Into<String>) -> Self {
126        Self {
127            level: MessageLevel::Warning,
128            message: message.into(),
129            dismiss_after: Some(10),
130        }
131    }
132
133    /// Create an info message
134    pub fn info(message: impl Into<String>) -> Self {
135        Self {
136            level: MessageLevel::Info,
137            message: message.into(),
138            dismiss_after: Some(5),
139        }
140    }
141}
142
143/// Message level
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145pub enum MessageLevel {
146    Success,
147    Error,
148    Warning,
149    Info,
150}
151
152impl MessageLevel {
153    /// Get CSS class for this level
154    pub fn css_class(&self) -> &'static str {
155        match self {
156            Self::Success => "alert-success",
157            Self::Error => "alert-error",
158            Self::Warning => "alert-warning",
159            Self::Info => "alert-info",
160        }
161    }
162
163    /// Get icon for this level
164    pub fn icon(&self) -> &'static str {
165        match self {
166            Self::Success => "check-circle",
167            Self::Error => "x-circle",
168            Self::Warning => "alert-triangle",
169            Self::Info => "info",
170        }
171    }
172}
173
174/// Breadcrumb item
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct Breadcrumb {
177    /// Label
178    pub label: String,
179    /// URL (None for current page)
180    pub url: Option<String>,
181    /// Icon
182    pub icon: Option<String>,
183}
184
185impl Breadcrumb {
186    /// Create a breadcrumb
187    pub fn new(label: impl Into<String>) -> Self {
188        Self {
189            label: label.into(),
190            url: None,
191            icon: None,
192        }
193    }
194
195    /// With URL
196    pub fn url(mut self, url: impl Into<String>) -> Self {
197        self.url = Some(url.into());
198        self
199    }
200
201    /// With icon
202    pub fn icon(mut self, icon: impl Into<String>) -> Self {
203        self.icon = Some(icon.into());
204        self
205    }
206}
207
208/// Table column for list view
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct TableColumn {
211    /// Field name
212    pub field: String,
213    /// Display label
214    pub label: String,
215    /// Is sortable?
216    pub sortable: bool,
217    /// Current sort direction (if sorted)
218    pub sort_direction: Option<SortDirection>,
219    /// CSS class
220    pub css_class: Option<String>,
221    /// Width
222    pub width: Option<String>,
223}
224
225/// Sort direction
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227pub enum SortDirection {
228    Asc,
229    Desc,
230}
231
232/// Table row
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct TableRow {
235    /// Primary key value
236    pub id: String,
237    /// Cell values (field -> rendered value)
238    pub cells: Vec<TableCell>,
239    /// Is selected?
240    pub selected: bool,
241    /// Row CSS class
242    pub css_class: Option<String>,
243}
244
245/// Table cell
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct TableCell {
248    /// Field name
249    pub field: String,
250    /// Raw value
251    pub value: serde_json::Value,
252    /// Rendered HTML
253    pub rendered: String,
254    /// Cell type
255    pub cell_type: CellType,
256}
257
258/// Cell type for rendering
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260pub enum CellType {
261    Text,
262    Number,
263    Boolean,
264    Date,
265    DateTime,
266    Email,
267    Url,
268    Image,
269    Badge,
270    Actions,
271}
272
273/// Filter definition
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct FilterDef {
276    /// Field name
277    pub field: String,
278    /// Display label
279    pub label: String,
280    /// Filter type
281    pub filter_type: FilterType,
282    /// Available choices
283    pub choices: Vec<FilterChoice>,
284    /// Current value
285    pub current: Option<String>,
286}
287
288/// Filter type
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290pub enum FilterType {
291    /// Boolean (yes/no/all)
292    Boolean,
293    /// Select from choices
294    Select,
295    /// Date range
296    DateRange,
297    /// Number range
298    NumberRange,
299    /// Text search
300    Text,
301}
302
303/// Filter choice
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct FilterChoice {
306    /// Value
307    pub value: String,
308    /// Label
309    pub label: String,
310    /// Count of matching items
311    pub count: Option<usize>,
312}
313
314/// Statistics card for dashboard
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct StatCard {
317    /// Card title
318    pub title: String,
319    /// Main value
320    pub value: String,
321    /// Change from previous period
322    pub change: Option<StatChange>,
323    /// Icon
324    pub icon: Option<String>,
325    /// Card color
326    pub color: Option<String>,
327    /// Link URL
328    pub link: Option<String>,
329}
330
331/// Change indicator
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct StatChange {
334    /// Change value
335    pub value: String,
336    /// Is positive change?
337    pub positive: bool,
338    /// Period label (e.g., "vs last month")
339    pub period: String,
340}
341
342/// Quick action button
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct QuickAction {
345    /// Label
346    pub label: String,
347    /// URL
348    pub url: String,
349    /// Icon
350    pub icon: Option<String>,
351    /// CSS class
352    pub css_class: Option<String>,
353}
354
355/// Generate CSS for theme
356pub fn generate_admin_css(theme: &Theme) -> String {
357    let variables = theme.to_css_variables();
358
359    format!(
360        r#"{}
361
362/* Admin Base Styles */
363* {{
364  box-sizing: border-box;
365  margin: 0;
366  padding: 0;
367}}
368
369body {{
370  font-family: var(--admin-font);
371  background: var(--admin-bg);
372  color: var(--admin-text);
373  line-height: 1.5;
374}}
375
376/* Layout */
377.admin-layout {{
378  display: flex;
379  min-height: 100vh;
380}}
381
382.admin-sidebar {{
383  width: var(--admin-sidebar-width);
384  background: var(--admin-surface);
385  border-right: 1px solid var(--admin-border);
386  display: flex;
387  flex-direction: column;
388}}
389
390.admin-content {{
391  flex: 1;
392  overflow-x: auto;
393}}
394
395/* Navigation */
396.admin-nav {{
397  padding: 1rem;
398}}
399
400.admin-nav-item {{
401  display: flex;
402  align-items: center;
403  padding: 0.75rem 1rem;
404  color: var(--admin-text-muted);
405  text-decoration: none;
406  border-radius: var(--admin-radius);
407  transition: all 0.15s;
408}}
409
410.admin-nav-item:hover,
411.admin-nav-item.active {{
412  background: var(--admin-primary);
413  color: white;
414}}
415
416/* Cards */
417.admin-card {{
418  background: var(--admin-surface);
419  border: 1px solid var(--admin-border);
420  border-radius: var(--admin-radius);
421  padding: 1.5rem;
422}}
423
424/* Tables */
425.admin-table {{
426  width: 100%;
427  border-collapse: collapse;
428}}
429
430.admin-table th,
431.admin-table td {{
432  padding: 0.75rem 1rem;
433  text-align: left;
434  border-bottom: 1px solid var(--admin-border);
435}}
436
437.admin-table th {{
438  font-weight: 600;
439  color: var(--admin-text-muted);
440  font-size: 0.875rem;
441}}
442
443.admin-table tr:hover {{
444  background: rgba(255, 255, 255, 0.02);
445}}
446
447/* Forms */
448.admin-input {{
449  width: 100%;
450  padding: 0.5rem 0.75rem;
451  background: var(--admin-bg);
452  border: 1px solid var(--admin-border);
453  border-radius: var(--admin-radius);
454  color: var(--admin-text);
455  font-size: 0.875rem;
456}}
457
458.admin-input:focus {{
459  outline: none;
460  border-color: var(--admin-primary);
461  box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
462}}
463
464/* Buttons */
465.admin-btn {{
466  display: inline-flex;
467  align-items: center;
468  gap: 0.5rem;
469  padding: 0.5rem 1rem;
470  font-size: 0.875rem;
471  font-weight: 500;
472  border-radius: var(--admin-radius);
473  border: none;
474  cursor: pointer;
475  transition: all 0.15s;
476}}
477
478.admin-btn-primary {{
479  background: var(--admin-primary);
480  color: white;
481}}
482
483.admin-btn-primary:hover {{
484  filter: brightness(1.1);
485}}
486
487.admin-btn-danger {{
488  background: var(--admin-error);
489  color: white;
490}}
491
492/* Alerts */
493.admin-alert {{
494  padding: 1rem;
495  border-radius: var(--admin-radius);
496  margin-bottom: 1rem;
497}}
498
499.alert-success {{
500  background: rgba(34, 197, 94, 0.1);
501  border: 1px solid var(--admin-success);
502  color: var(--admin-success);
503}}
504
505.alert-error {{
506  background: rgba(239, 68, 68, 0.1);
507  border: 1px solid var(--admin-error);
508  color: var(--admin-error);
509}}
510
511/* Badges */
512.admin-badge {{
513  display: inline-flex;
514  padding: 0.25rem 0.5rem;
515  font-size: 0.75rem;
516  font-weight: 500;
517  border-radius: 9999px;
518}}
519
520.badge-success {{
521  background: rgba(34, 197, 94, 0.2);
522  color: var(--admin-success);
523}}
524
525.badge-warning {{
526  background: rgba(245, 158, 11, 0.2);
527  color: var(--admin-warning);
528}}
529
530.badge-error {{
531  background: rgba(239, 68, 68, 0.2);
532  color: var(--admin-error);
533}}
534
535/* Pagination */
536.admin-pagination {{
537  display: flex;
538  align-items: center;
539  gap: 0.25rem;
540}}
541
542.admin-page-btn {{
543  min-width: 2rem;
544  height: 2rem;
545  display: flex;
546  align-items: center;
547  justify-content: center;
548  border-radius: var(--admin-radius);
549  border: 1px solid var(--admin-border);
550  background: transparent;
551  color: var(--admin-text);
552  cursor: pointer;
553}}
554
555.admin-page-btn.active {{
556  background: var(--admin-primary);
557  border-color: var(--admin-primary);
558  color: white;
559}}
560"#,
561        variables
562    )
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn test_pagination() {
571        let pagination = Pagination::new(1, 10, 95);
572
573        assert_eq!(pagination.total_pages, 10);
574        assert!(pagination.has_next);
575        assert!(!pagination.has_prev);
576        assert_eq!(pagination.start_item, 1);
577        assert_eq!(pagination.end_item, 10);
578    }
579
580    #[test]
581    fn test_pagination_empty() {
582        let pagination = Pagination::new(1, 10, 0);
583
584        assert_eq!(pagination.total_pages, 0);
585        assert!(!pagination.has_next);
586        assert!(!pagination.has_prev);
587        assert_eq!(pagination.start_item, 0);
588    }
589
590    #[test]
591    fn test_flash_message() {
592        let msg = FlashMessage::success("Record saved");
593        assert_eq!(msg.level, MessageLevel::Success);
594        assert_eq!(msg.dismiss_after, Some(5));
595    }
596}
597