1use crate::config::Theme;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Pagination {
9 pub page: usize,
11 pub total_pages: usize,
13 pub per_page: usize,
15 pub total_items: usize,
17 pub has_prev: bool,
19 pub has_next: bool,
21 pub start_item: usize,
23 pub end_item: usize,
25}
26
27impl Pagination {
28 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 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 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 if start > 2 {
63 pages.push(PageNumber::Ellipsis);
64 }
65
66 for p in start..=end {
68 pages.push(PageNumber::Page(p));
69 }
70
71 if end < self.total_pages - 1 {
73 pages.push(PageNumber::Ellipsis);
74 }
75
76 if self.total_pages > 1 {
78 pages.push(PageNumber::Page(self.total_pages));
79 }
80
81 pages
82 }
83}
84
85#[derive(Debug, Clone, Copy)]
87pub enum PageNumber {
88 Page(usize),
90 Ellipsis,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct FlashMessage {
97 pub level: MessageLevel,
99 pub message: String,
101 pub dismiss_after: Option<u32>,
103}
104
105impl FlashMessage {
106 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145pub enum MessageLevel {
146 Success,
147 Error,
148 Warning,
149 Info,
150}
151
152impl MessageLevel {
153 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct Breadcrumb {
177 pub label: String,
179 pub url: Option<String>,
181 pub icon: Option<String>,
183}
184
185impl Breadcrumb {
186 pub fn new(label: impl Into<String>) -> Self {
188 Self {
189 label: label.into(),
190 url: None,
191 icon: None,
192 }
193 }
194
195 pub fn url(mut self, url: impl Into<String>) -> Self {
197 self.url = Some(url.into());
198 self
199 }
200
201 pub fn icon(mut self, icon: impl Into<String>) -> Self {
203 self.icon = Some(icon.into());
204 self
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct TableColumn {
211 pub field: String,
213 pub label: String,
215 pub sortable: bool,
217 pub sort_direction: Option<SortDirection>,
219 pub css_class: Option<String>,
221 pub width: Option<String>,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227pub enum SortDirection {
228 Asc,
229 Desc,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct TableRow {
235 pub id: String,
237 pub cells: Vec<TableCell>,
239 pub selected: bool,
241 pub css_class: Option<String>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct TableCell {
248 pub field: String,
250 pub value: serde_json::Value,
252 pub rendered: String,
254 pub cell_type: CellType,
256}
257
258#[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#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct FilterDef {
276 pub field: String,
278 pub label: String,
280 pub filter_type: FilterType,
282 pub choices: Vec<FilterChoice>,
284 pub current: Option<String>,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290pub enum FilterType {
291 Boolean,
293 Select,
295 DateRange,
297 NumberRange,
299 Text,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct FilterChoice {
306 pub value: String,
308 pub label: String,
310 pub count: Option<usize>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct StatCard {
317 pub title: String,
319 pub value: String,
321 pub change: Option<StatChange>,
323 pub icon: Option<String>,
325 pub color: Option<String>,
327 pub link: Option<String>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct StatChange {
334 pub value: String,
336 pub positive: bool,
338 pub period: String,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct QuickAction {
345 pub label: String,
347 pub url: String,
349 pub icon: Option<String>,
351 pub css_class: Option<String>,
353}
354
355pub 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