alpe-temp 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontend-project/src/App.css +1 -0
- package/frontend-project/src/Auth/Login.jsx +85 -0
- package/frontend-project/src/Auth/Register.jsx +183 -0
- package/frontend-project/src/Intro.jsx +33 -0
- package/frontend-project/src/LayOut.jsx +38 -0
- package/frontend-project/src/api/ApiClient.js +92 -0
- package/frontend-project/src/assets/hero.png +0 -0
- package/frontend-project/src/assets/react.svg +1 -0
- package/frontend-project/src/assets/vite.svg +1 -0
- package/frontend-project/src/components/Aside.jsx +9 -0
- package/frontend-project/src/components/Button.jsx +100 -0
- package/frontend-project/src/components/Card.jsx +104 -0
- package/frontend-project/src/components/FormField.jsx +129 -0
- package/frontend-project/src/components/Modal.jsx +106 -0
- package/frontend-project/src/components/Table.jsx +127 -0
- package/frontend-project/src/components/Toast.jsx +64 -0
- package/frontend-project/src/components/index.js +14 -0
- package/frontend-project/src/config.js +66 -0
- package/frontend-project/src/design.js +115 -0
- package/frontend-project/src/index.css +60 -0
- package/frontend-project/src/layouts/BottomNav.jsx +156 -0
- package/frontend-project/src/layouts/TopNav.jsx +150 -0
- package/frontend-project/src/layouts/useShell.js +44 -0
- package/frontend-project/src/main.jsx +41 -0
- package/frontend-project/src/pages/Department.jsx +188 -0
- package/frontend-project/src/pages/Employee.jsx +274 -0
- package/frontend-project/src/pages/Home.jsx +79 -0
- package/frontend-project/src/pages/Profile.jsx +9 -0
- package/frontend-project/src/pages/Register.jsx +57 -0
- package/frontend-project/src/pages/Reports.jsx +91 -0
- package/frontend-project/src/pages/Salary.jsx +264 -0
- package/frontend-project/src/themes.js +175 -0
- package/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// 🃏 CARD — Reusable card & stat card with config-driven styling
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// HOW TO USE:
|
|
6
|
+
// <Card title="Users" action={<Button>Add</Button>}>
|
|
7
|
+
// <p>Content goes here</p>
|
|
8
|
+
// </Card>
|
|
9
|
+
//
|
|
10
|
+
// <StatCard label="Total Employees" value="150" trend="+12%" />
|
|
11
|
+
//
|
|
12
|
+
// PROPS (Card):
|
|
13
|
+
// title: card heading text
|
|
14
|
+
// subtitle: smaller text below title
|
|
15
|
+
// action: element to show on the right (e.g. a Button)
|
|
16
|
+
// children: card body content
|
|
17
|
+
// className: additional CSS classes
|
|
18
|
+
//
|
|
19
|
+
// PROPS (StatCard):
|
|
20
|
+
// label: stat label
|
|
21
|
+
// value: stat value (string or number)
|
|
22
|
+
// trend: optional trend text (e.g. "+12%")
|
|
23
|
+
// trendUp: whether trend is positive (true=green, false=red)
|
|
24
|
+
// loading: show skeleton placeholder
|
|
25
|
+
// className: additional CSS classes
|
|
26
|
+
//
|
|
27
|
+
// CONFIG INTEGRATION:
|
|
28
|
+
// - Background from var(--color-card)
|
|
29
|
+
// - Border from var(--color-border)
|
|
30
|
+
//
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
|
|
33
|
+
import React from 'react';
|
|
34
|
+
import { config } from '../config';
|
|
35
|
+
import { getRoundedClass } from '../design';
|
|
36
|
+
|
|
37
|
+
export function Card({ title, subtitle, action, children, className = '' }) {
|
|
38
|
+
const roundClass = getRoundedClass(config.rounded);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className={`p-5 ${roundClass} ${className}`}
|
|
43
|
+
style={{
|
|
44
|
+
backgroundColor: 'var(--color-card)',
|
|
45
|
+
border: '1px solid var(--color-border)',
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{(title || action) && (
|
|
49
|
+
<div className="flex items-start justify-between mb-4">
|
|
50
|
+
<div>
|
|
51
|
+
{title && (
|
|
52
|
+
<h3 className="text-[13px] font-semibold" style={{ color: 'var(--color-text)' }}>
|
|
53
|
+
{title}
|
|
54
|
+
</h3>
|
|
55
|
+
)}
|
|
56
|
+
{subtitle && (
|
|
57
|
+
<p className="text-[11px] mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
|
|
58
|
+
{subtitle}
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
{action && <div className="flex-shrink-0">{action}</div>}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function StatCard({ label, value, trend, trendUp = true, loading = false, className = '' }) {
|
|
71
|
+
const roundClass = getRoundedClass(config.rounded);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
className={`p-5 flex flex-col gap-3 ${roundClass} ${className}`}
|
|
76
|
+
style={{
|
|
77
|
+
backgroundColor: 'var(--color-card)',
|
|
78
|
+
border: '1px solid var(--color-border)',
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<div className="flex items-center justify-between">
|
|
82
|
+
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: 'var(--color-text-muted)' }}>
|
|
83
|
+
{label}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
{loading ? (
|
|
87
|
+
<div className="h-8 w-24 animate-pulse" style={{ backgroundColor: 'var(--color-border)' }} />
|
|
88
|
+
) : (
|
|
89
|
+
<span className="text-[32px] font-bold leading-none" style={{ color: 'var(--color-text)' }}>
|
|
90
|
+
{value ?? '—'}
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
93
|
+
{trend && (
|
|
94
|
+
<span
|
|
95
|
+
className={`text-[11px] font-semibold px-2 py-0.5 w-fit ${
|
|
96
|
+
trendUp ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-500'
|
|
97
|
+
}`}
|
|
98
|
+
>
|
|
99
|
+
{trend}
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// 📝 FORM FIELD — Reusable form inputs with labels & validation
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// HOW TO USE:
|
|
6
|
+
// <FormField label="Email" required error={errors.email}>
|
|
7
|
+
// <FormField.Input type="email" value={email} onChange={setEmail} />
|
|
8
|
+
// </FormField>
|
|
9
|
+
//
|
|
10
|
+
// <FormField label="Role">
|
|
11
|
+
// <FormField.Select options={['admin', 'user']} value={role} onChange={setRole} />
|
|
12
|
+
// </FormField>
|
|
13
|
+
//
|
|
14
|
+
// <FormField label="Bio">
|
|
15
|
+
// <FormField.Textarea value={bio} onChange={setBio} />
|
|
16
|
+
// </FormField>
|
|
17
|
+
//
|
|
18
|
+
// PROPS:
|
|
19
|
+
// label: field label text
|
|
20
|
+
// required: show red asterisk
|
|
21
|
+
// error: error message string
|
|
22
|
+
// children: input element(s)
|
|
23
|
+
// className: additional CSS classes
|
|
24
|
+
//
|
|
25
|
+
// SUB-COMPONENTS:
|
|
26
|
+
// FormField.Input → <input>
|
|
27
|
+
// FormField.Select → <select>
|
|
28
|
+
// FormField.Textarea → <textarea>
|
|
29
|
+
//
|
|
30
|
+
// CONFIG INTEGRATION:
|
|
31
|
+
// - Rounded corners from config.js → --radius CSS variable
|
|
32
|
+
// - Colors from CSS variables
|
|
33
|
+
//
|
|
34
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
35
|
+
|
|
36
|
+
import React from 'react';
|
|
37
|
+
import { config } from '../config';
|
|
38
|
+
import { getRoundedClass } from '../design';
|
|
39
|
+
|
|
40
|
+
// ─── BASE INPUT STYLES ──────────────────────────────────────────────────────
|
|
41
|
+
const INPUT_BASE =
|
|
42
|
+
'w-full px-3 py-2 text-[13px] font-medium ' +
|
|
43
|
+
'border transition-colors duration-150 ' +
|
|
44
|
+
'placeholder:text-gray-400 focus:outline-none focus:ring-1 ' +
|
|
45
|
+
'disabled:bg-gray-50 disabled:text-gray-400';
|
|
46
|
+
|
|
47
|
+
// ─── MAIN FORM FIELD COMPONENT ──────────────────────────────────────────────
|
|
48
|
+
export default function FormField({ label, required = false, error, children, className = '' }) {
|
|
49
|
+
return (
|
|
50
|
+
<div className={`flex flex-col gap-1 ${className}`}>
|
|
51
|
+
{label && (
|
|
52
|
+
<label className="text-[12px] font-semibold select-none" style={{ color: 'var(--color-text-muted)' }}>
|
|
53
|
+
{label}
|
|
54
|
+
{required && <span className="text-red-400 ml-0.5">*</span>}
|
|
55
|
+
</label>
|
|
56
|
+
)}
|
|
57
|
+
{children}
|
|
58
|
+
{error && <p className="text-[11px] text-red-500 font-medium">{error}</p>}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── TEXT INPUT ──────────────────────────────────────────────────────────────
|
|
64
|
+
FormField.Input = function FieldInput({ onChange, className = '', ...props }) {
|
|
65
|
+
const roundClass = getRoundedClass(config.rounded);
|
|
66
|
+
return (
|
|
67
|
+
<input
|
|
68
|
+
className={`${INPUT_BASE} ${roundClass} ${className}`}
|
|
69
|
+
style={{
|
|
70
|
+
backgroundColor: 'var(--color-card)',
|
|
71
|
+
borderColor: 'var(--color-border)',
|
|
72
|
+
color: 'var(--color-text)',
|
|
73
|
+
}}
|
|
74
|
+
onChange={(e) => onChange?.(e.target.value, e)}
|
|
75
|
+
{...props}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ─── SELECT DROPDOWN ─────────────────────────────────────────────────────────
|
|
81
|
+
FormField.Select = function FieldSelect({ value, onChange, options = [], placeholder, className = '', ...props }) {
|
|
82
|
+
const roundClass = getRoundedClass(config.rounded);
|
|
83
|
+
const normalised = options.map((o) =>
|
|
84
|
+
typeof o === 'string' ? { value: o, label: o } : o
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<select
|
|
89
|
+
value={value}
|
|
90
|
+
onChange={(e) => onChange?.(e.target.value, e)}
|
|
91
|
+
className={`${INPUT_BASE} ${roundClass} ${className}`}
|
|
92
|
+
style={{
|
|
93
|
+
backgroundColor: 'var(--color-card)',
|
|
94
|
+
borderColor: 'var(--color-border)',
|
|
95
|
+
color: 'var(--color-text)',
|
|
96
|
+
}}
|
|
97
|
+
{...props}
|
|
98
|
+
>
|
|
99
|
+
{placeholder && (
|
|
100
|
+
<option value="" disabled>
|
|
101
|
+
{placeholder}
|
|
102
|
+
</option>
|
|
103
|
+
)}
|
|
104
|
+
{normalised.map((o) => (
|
|
105
|
+
<option key={o.value} value={o.value}>
|
|
106
|
+
{o.label}
|
|
107
|
+
</option>
|
|
108
|
+
))}
|
|
109
|
+
</select>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ─── TEXTAREA ────────────────────────────────────────────────────────────────
|
|
114
|
+
FormField.Textarea = function FieldTextarea({ onChange, className = '', ...props }) {
|
|
115
|
+
const roundClass = getRoundedClass(config.rounded);
|
|
116
|
+
return (
|
|
117
|
+
<textarea
|
|
118
|
+
rows={3}
|
|
119
|
+
className={`${INPUT_BASE} resize-none ${roundClass} ${className}`}
|
|
120
|
+
style={{
|
|
121
|
+
backgroundColor: 'var(--color-card)',
|
|
122
|
+
borderColor: 'var(--color-border)',
|
|
123
|
+
color: 'var(--color-text)',
|
|
124
|
+
}}
|
|
125
|
+
onChange={(e) => onChange?.(e.target.value, e)}
|
|
126
|
+
{...props}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// 🪟 MODAL — Backdrop modal with config-driven styling
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS IS:
|
|
6
|
+
// A modal dialog that appears over everything with a dark backdrop.
|
|
7
|
+
// Press Escape or click outside to close.
|
|
8
|
+
//
|
|
9
|
+
// HOW TO USE:
|
|
10
|
+
// <Modal open={isOpen} onClose={() => setOpen(false)} title="Edit Item">
|
|
11
|
+
// <p>Modal content here</p>
|
|
12
|
+
// <Modal.Footer>
|
|
13
|
+
// <Button onClick={() => setOpen(false)}>Cancel</Button>
|
|
14
|
+
// </Modal.Footer>
|
|
15
|
+
// </Modal>
|
|
16
|
+
//
|
|
17
|
+
// PROPS:
|
|
18
|
+
// open: boolean (show/hide)
|
|
19
|
+
// onClose: function to close
|
|
20
|
+
// title: modal header text
|
|
21
|
+
// size: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
|
22
|
+
// children: modal body
|
|
23
|
+
// className: additional CSS classes
|
|
24
|
+
//
|
|
25
|
+
// CONFIG INTEGRATION:
|
|
26
|
+
// - Rounded corners from config.js
|
|
27
|
+
// - Colors from CSS variables
|
|
28
|
+
//
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
30
|
+
|
|
31
|
+
import React, { useEffect } from 'react';
|
|
32
|
+
import { createPortal } from 'react-dom';
|
|
33
|
+
import { X } from 'lucide-react';
|
|
34
|
+
import { config } from '../config';
|
|
35
|
+
import { getRoundedClass } from '../design';
|
|
36
|
+
|
|
37
|
+
const SIZES = {
|
|
38
|
+
sm: 'max-w-sm',
|
|
39
|
+
md: 'max-w-md',
|
|
40
|
+
lg: 'max-w-lg',
|
|
41
|
+
xl: 'max-w-xl',
|
|
42
|
+
'2xl': 'max-w-2xl',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default function Modal({ open, onClose, title, size = 'md', children, className = '' }) {
|
|
46
|
+
const roundClass = getRoundedClass(config.rounded);
|
|
47
|
+
|
|
48
|
+
// Close on Escape key
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open) return;
|
|
51
|
+
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
|
52
|
+
window.addEventListener('keydown', handler);
|
|
53
|
+
return () => window.removeEventListener('keydown', handler);
|
|
54
|
+
}, [open, onClose]);
|
|
55
|
+
|
|
56
|
+
if (!open) return null;
|
|
57
|
+
|
|
58
|
+
return createPortal(
|
|
59
|
+
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true">
|
|
60
|
+
{/* Backdrop */}
|
|
61
|
+
<div
|
|
62
|
+
className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"
|
|
63
|
+
onClick={onClose}
|
|
64
|
+
/>
|
|
65
|
+
{/* Modal content */}
|
|
66
|
+
<div
|
|
67
|
+
className={`relative shadow-xl w-full ${SIZES[size] ?? SIZES.md} ${roundClass} ${className}`}
|
|
68
|
+
style={{
|
|
69
|
+
backgroundColor: 'var(--color-card)',
|
|
70
|
+
border: '1px solid var(--color-border)',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{title && (
|
|
74
|
+
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
|
75
|
+
<h2 className="text-[14px] font-semibold" style={{ color: 'var(--color-text)' }}>
|
|
76
|
+
{title}
|
|
77
|
+
</h2>
|
|
78
|
+
<button
|
|
79
|
+
onClick={onClose}
|
|
80
|
+
className="w-7 h-7 flex items-center justify-center transition-colors hover:opacity-70"
|
|
81
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
82
|
+
>
|
|
83
|
+
<X className="w-4 h-4" />
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
<div className="px-5 py-4">{children}</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>,
|
|
90
|
+
document.body
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── MODAL FOOTER ────────────────────────────────────────────────────────────
|
|
95
|
+
// Use inside <Modal> to add a footer with action buttons.
|
|
96
|
+
//
|
|
97
|
+
Modal.Footer = function ModalFooter({ children }) {
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
className="flex justify-end gap-2 pt-3 mt-2 border-t"
|
|
101
|
+
style={{ borderColor: 'var(--color-border)' }}
|
|
102
|
+
>
|
|
103
|
+
{children}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// 📊 TABLE — Reusable data table with loading & empty states
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// HOW TO USE:
|
|
6
|
+
// <Table
|
|
7
|
+
// columns={[
|
|
8
|
+
// { key: 'name', label: 'Name' },
|
|
9
|
+
// { key: 'email', label: 'Email', align: 'center' },
|
|
10
|
+
// { key: 'status', label: 'Status', render: (val) => <Badge>{val}</Badge> },
|
|
11
|
+
// ]}
|
|
12
|
+
// data={users}
|
|
13
|
+
// rowKey="id"
|
|
14
|
+
// />
|
|
15
|
+
//
|
|
16
|
+
// PROPS:
|
|
17
|
+
// columns: array of { key, label, align?, render? }
|
|
18
|
+
// data: array of row objects
|
|
19
|
+
// rowKey: unique key per row (default: 'id')
|
|
20
|
+
// loading: show loading skeleton
|
|
21
|
+
// emptyMessage: text when no data
|
|
22
|
+
// className: additional CSS classes
|
|
23
|
+
// maxHeight: max scrollable height (default: '360px')
|
|
24
|
+
//
|
|
25
|
+
// CONFIG INTEGRATION:
|
|
26
|
+
// - Rounded corners from config.js
|
|
27
|
+
// - Colors from CSS variables
|
|
28
|
+
//
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
30
|
+
|
|
31
|
+
import React from 'react';
|
|
32
|
+
import { config } from '../config';
|
|
33
|
+
import { getRoundedClass } from '../design';
|
|
34
|
+
|
|
35
|
+
export default function Table({
|
|
36
|
+
columns,
|
|
37
|
+
data = [],
|
|
38
|
+
rowKey = 'id',
|
|
39
|
+
loading = false,
|
|
40
|
+
emptyMessage = 'No data found',
|
|
41
|
+
className = '',
|
|
42
|
+
maxHeight = '360px',
|
|
43
|
+
}) {
|
|
44
|
+
const ALIGN = { left: 'text-left', center: 'text-center', right: 'text-right' };
|
|
45
|
+
const roundClass = getRoundedClass(config.rounded);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className={`w-full overflow-auto border ${roundClass} ${className}`}
|
|
50
|
+
style={{ maxHeight, borderColor: 'var(--color-border)' }}
|
|
51
|
+
>
|
|
52
|
+
<table className="w-full min-w-max">
|
|
53
|
+
{/* ─── HEADER ──────────────────────────────────────────────────── */}
|
|
54
|
+
<thead className="sticky top-0 z-10">
|
|
55
|
+
<tr
|
|
56
|
+
className="border-b"
|
|
57
|
+
style={{
|
|
58
|
+
backgroundColor: 'var(--color-surface)',
|
|
59
|
+
borderColor: 'var(--color-border)',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{columns.map((col) => (
|
|
63
|
+
<th
|
|
64
|
+
key={col.key}
|
|
65
|
+
className={`px-4 py-3 text-[11px] font-semibold uppercase tracking-wider whitespace-nowrap ${ALIGN[col.align ?? 'left']}`}
|
|
66
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
67
|
+
>
|
|
68
|
+
{col.label}
|
|
69
|
+
</th>
|
|
70
|
+
))}
|
|
71
|
+
</tr>
|
|
72
|
+
</thead>
|
|
73
|
+
|
|
74
|
+
{/* ─── BODY ────────────────────────────────────────────────────── */}
|
|
75
|
+
<tbody className="divide-y" style={{ backgroundColor: 'var(--color-card)', borderColor: 'var(--color-border)' }}>
|
|
76
|
+
{/* Loading skeleton */}
|
|
77
|
+
{loading ? (
|
|
78
|
+
Array.from({ length: 5 }).map((_, i) => (
|
|
79
|
+
<tr key={i}>
|
|
80
|
+
{columns.map((col) => (
|
|
81
|
+
<td key={col.key} className="px-4 py-3">
|
|
82
|
+
<div
|
|
83
|
+
className="h-3 animate-pulse"
|
|
84
|
+
style={{
|
|
85
|
+
width: `${60 + (i * 13 + col.key.length * 7) % 30}%`,
|
|
86
|
+
backgroundColor: 'var(--color-border)',
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
</td>
|
|
90
|
+
))}
|
|
91
|
+
</tr>
|
|
92
|
+
))
|
|
93
|
+
) : data.length === 0 ? (
|
|
94
|
+
// Empty state
|
|
95
|
+
<tr>
|
|
96
|
+
<td
|
|
97
|
+
colSpan={columns.length}
|
|
98
|
+
className="px-4 py-10 text-center text-[13px]"
|
|
99
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
100
|
+
>
|
|
101
|
+
{emptyMessage}
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
) : (
|
|
105
|
+
// Data rows
|
|
106
|
+
data.map((row, rowIdx) => (
|
|
107
|
+
<tr
|
|
108
|
+
key={row[rowKey] ?? rowIdx}
|
|
109
|
+
className="transition-colors duration-100 hover:bg-gray-50"
|
|
110
|
+
>
|
|
111
|
+
{columns.map((col) => (
|
|
112
|
+
<td
|
|
113
|
+
key={col.key}
|
|
114
|
+
className={`px-4 py-3 text-[12px] font-medium whitespace-nowrap ${ALIGN[col.align ?? 'left']}`}
|
|
115
|
+
style={{ color: 'var(--color-text)' }}
|
|
116
|
+
>
|
|
117
|
+
{col.render ? col.render(row[col.key], row) : (row[col.key] ?? '—')}
|
|
118
|
+
</td>
|
|
119
|
+
))}
|
|
120
|
+
</tr>
|
|
121
|
+
))
|
|
122
|
+
)}
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useState } from 'react';
|
|
2
|
+
import { CheckCircle, XCircle, Info, X } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
const ToastContext = createContext(null);
|
|
5
|
+
|
|
6
|
+
const ICONS = {
|
|
7
|
+
success: <CheckCircle className="w-4 h-4 text-emerald-500 flex-shrink-0" />,
|
|
8
|
+
error: <XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />,
|
|
9
|
+
info: <Info className="w-4 h-4 text-blue-500 flex-shrink-0" />,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const BG = {
|
|
13
|
+
success: 'bg-white border-emerald-200',
|
|
14
|
+
error: 'bg-white border-red-200',
|
|
15
|
+
info: 'bg-white border-blue-200',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let _id = 0;
|
|
19
|
+
|
|
20
|
+
export function ToastProvider({ children }) {
|
|
21
|
+
const [toasts, setToasts] = useState([]);
|
|
22
|
+
|
|
23
|
+
const add = useCallback((message, type = 'info', duration = 3500) => {
|
|
24
|
+
const id = ++_id;
|
|
25
|
+
setToasts((prev) => [...prev, { id, message, type }]);
|
|
26
|
+
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), duration);
|
|
27
|
+
return id;
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
const remove = useCallback((id) => setToasts((prev) => prev.filter((t) => t.id !== id)), []);
|
|
31
|
+
|
|
32
|
+
const api = {
|
|
33
|
+
success: (msg, dur) => add(msg, 'success', dur),
|
|
34
|
+
error: (msg, dur) => add(msg, 'error', dur),
|
|
35
|
+
info: (msg, dur) => add(msg, 'info', dur),
|
|
36
|
+
remove,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<ToastContext.Provider value={api}>
|
|
41
|
+
{children}
|
|
42
|
+
<div className="fixed top-5 right-5 z-[9999] flex flex-col gap-2 pointer-events-none">
|
|
43
|
+
{toasts.map((t) => (
|
|
44
|
+
<div
|
|
45
|
+
key={t.id}
|
|
46
|
+
className={`pointer-events-auto flex items-center gap-3 px-4 py-3 border shadow-lg text-[13px] font-medium text-gray-700 min-w-[240px] max-w-xs ${BG[t.type]}`}
|
|
47
|
+
>
|
|
48
|
+
{ICONS[t.type]}
|
|
49
|
+
<span className="flex-1">{t.message}</span>
|
|
50
|
+
<button onClick={() => remove(t.id)} className="text-gray-300 hover:text-gray-500 transition-colors">
|
|
51
|
+
<X className="w-3.5 h-3.5" />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</ToastContext.Provider>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useToast() {
|
|
61
|
+
const ctx = useContext(ToastContext);
|
|
62
|
+
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
|
|
63
|
+
return ctx;
|
|
64
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/components/ui/index.js
|
|
3
|
+
* Barrel export for all reusable UI primitives.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import { Button, Table, Modal, FormField, Card, StatCard, ToastProvider, useToast } from '../components/ui';
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { default as Button } from './Button';
|
|
10
|
+
export { default as Table } from './Table';
|
|
11
|
+
export { default as Modal } from './Modal';
|
|
12
|
+
export { default as FormField } from './FormField';
|
|
13
|
+
export { Card, StatCard } from './Card';
|
|
14
|
+
export { ToastProvider, useToast } from './Toast';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// 🎨 APP CONFIGURATION — Change ONE value to transform the whole app!
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// 📖 BEGINNER GUIDE:
|
|
6
|
+
//
|
|
7
|
+
// This is the ONLY file you need to touch to change the entire look
|
|
8
|
+
// and behavior of the app. Every page, component, and layout reads
|
|
9
|
+
// from this file.
|
|
10
|
+
//
|
|
11
|
+
// 🚀 QUICK EXAMPLES:
|
|
12
|
+
//
|
|
13
|
+
// 1. navigation: 'bottomnav' → navbar moves to the bottom (mobile style)
|
|
14
|
+
// 2. theme: 'midnight' → dark mode, instantly
|
|
15
|
+
// 3. rounded: 'none' → all buttons/cards become square
|
|
16
|
+
// 4. fontSize: 'large' → bigger text everywhere
|
|
17
|
+
// 5. colors.primary: '#FF0000' → brand color changes to red
|
|
18
|
+
//
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
20
|
+
|
|
21
|
+
export const config = {
|
|
22
|
+
// ─── NAVIGATION ───────────────────────────────────────────────────────
|
|
23
|
+
// 'topnav' → horizontal bar at the TOP of the screen
|
|
24
|
+
// 'bottomnav' → bar at the BOTTOM (great for mobile / app-like feel)
|
|
25
|
+
navigation: 'bottomnav',
|
|
26
|
+
|
|
27
|
+
// ─── THEME ────────────────────────────────────────────────────────────
|
|
28
|
+
// Pick any theme name from themes.js. Available themes:
|
|
29
|
+
// 'forest' (green - default)
|
|
30
|
+
// 'midnight' (dark)
|
|
31
|
+
// 'slate' (blue-gray)
|
|
32
|
+
// 'rose' (pink/crimson)
|
|
33
|
+
// 'amber' (orange/gold)
|
|
34
|
+
// 'ocean' (navy/cyan)
|
|
35
|
+
// 'chalk' (black & white)
|
|
36
|
+
// 'violet' (purple)
|
|
37
|
+
theme: 'forest',
|
|
38
|
+
|
|
39
|
+
// ─── BORDER RADIUS ────────────────────────────────────────────────────
|
|
40
|
+
// Controls how rounded buttons, cards, inputs, and modals are.
|
|
41
|
+
// 'none' → completely square (sharp, modern)
|
|
42
|
+
// 'sm' → slightly rounded
|
|
43
|
+
// 'md' → moderately rounded (default feel)
|
|
44
|
+
// 'lg' → very rounded (pill-like)
|
|
45
|
+
// 'xl' → extra rounded
|
|
46
|
+
// 'full' → fully pill-shaped
|
|
47
|
+
rounded: 'md',
|
|
48
|
+
|
|
49
|
+
// ─── FONT SIZE ────────────────────────────────────────────────────────
|
|
50
|
+
// 'normal' → default text size
|
|
51
|
+
// 'large' → bigger text (easier to read)
|
|
52
|
+
fontSize: 'normal',
|
|
53
|
+
|
|
54
|
+
// ─── COLORS ───────────────────────────────────────────────────────────
|
|
55
|
+
// Leave empty ('') to use the theme's default colors.
|
|
56
|
+
// Set a value to override that specific color.
|
|
57
|
+
// Format: any valid CSS color (#hex, rgb, name)
|
|
58
|
+
colors: {
|
|
59
|
+
primary: '', // main brand color (buttons, links, active nav)
|
|
60
|
+
// secondary: '', // secondary accent
|
|
61
|
+
// success: '', // success states (green)
|
|
62
|
+
// danger: '', // error/danger states (red)
|
|
63
|
+
// warning: '', // warning states (yellow/amber)
|
|
64
|
+
// info: '', // info states (blue)
|
|
65
|
+
},
|
|
66
|
+
};
|