forge-admin 0.0.0
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/README.md +73 -0
- package/app.db +0 -0
- package/components.json +20 -0
- package/dist/assets/index-BPVmexx_.css +1 -0
- package/dist/assets/index-BtNewH3n.js +258 -0
- package/dist/favicon.ico +0 -0
- package/dist/index.html +27 -0
- package/dist/placeholder.svg +1 -0
- package/dist/robots.txt +14 -0
- package/eslint.config.js +26 -0
- package/index.html +26 -0
- package/package.json +107 -0
- package/postcss.config.js +6 -0
- package/public/favicon.ico +0 -0
- package/public/placeholder.svg +1 -0
- package/public/robots.txt +14 -0
- package/src/App.css +42 -0
- package/src/App.tsx +32 -0
- package/src/admin/convertSchema.ts +83 -0
- package/src/admin/factory.ts +12 -0
- package/src/admin/introspecter.ts +6 -0
- package/src/admin/router.ts +38 -0
- package/src/admin/schema.ts +17 -0
- package/src/admin/sqlite.ts +73 -0
- package/src/admin/types.ts +35 -0
- package/src/components/AdminLayout.tsx +19 -0
- package/src/components/AdminSidebar.tsx +102 -0
- package/src/components/DataTable.tsx +166 -0
- package/src/components/ModelForm.tsx +221 -0
- package/src/components/NavLink.tsx +28 -0
- package/src/components/StatCard.tsx +32 -0
- package/src/components/ui/accordion.tsx +52 -0
- package/src/components/ui/alert-dialog.tsx +104 -0
- package/src/components/ui/alert.tsx +43 -0
- package/src/components/ui/aspect-ratio.tsx +5 -0
- package/src/components/ui/avatar.tsx +38 -0
- package/src/components/ui/badge.tsx +29 -0
- package/src/components/ui/breadcrumb.tsx +90 -0
- package/src/components/ui/button.tsx +47 -0
- package/src/components/ui/calendar.tsx +54 -0
- package/src/components/ui/card.tsx +43 -0
- package/src/components/ui/carousel.tsx +224 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/checkbox.tsx +26 -0
- package/src/components/ui/collapsible.tsx +9 -0
- package/src/components/ui/command.tsx +132 -0
- package/src/components/ui/context-menu.tsx +178 -0
- package/src/components/ui/dialog.tsx +95 -0
- package/src/components/ui/drawer.tsx +87 -0
- package/src/components/ui/dropdown-menu.tsx +179 -0
- package/src/components/ui/form.tsx +129 -0
- package/src/components/ui/hover-card.tsx +27 -0
- package/src/components/ui/input-otp.tsx +61 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +17 -0
- package/src/components/ui/menubar.tsx +207 -0
- package/src/components/ui/navigation-menu.tsx +120 -0
- package/src/components/ui/pagination.tsx +81 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +23 -0
- package/src/components/ui/radio-group.tsx +36 -0
- package/src/components/ui/resizable.tsx +37 -0
- package/src/components/ui/scroll-area.tsx +38 -0
- package/src/components/ui/select.tsx +143 -0
- package/src/components/ui/separator.tsx +20 -0
- package/src/components/ui/sheet.tsx +107 -0
- package/src/components/ui/sidebar.tsx +637 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +23 -0
- package/src/components/ui/sonner.tsx +27 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/textarea.tsx +21 -0
- package/src/components/ui/toast.tsx +111 -0
- package/src/components/ui/toaster.tsx +24 -0
- package/src/components/ui/toggle-group.tsx +49 -0
- package/src/components/ui/toggle.tsx +37 -0
- package/src/components/ui/tooltip.tsx +28 -0
- package/src/components/ui/use-toast.ts +3 -0
- package/src/config/define.ts +6 -0
- package/src/config/index.ts +0 -0
- package/src/config/load.ts +45 -0
- package/src/config/types.ts +5 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/hooks/use-toast.ts +186 -0
- package/src/index.css +142 -0
- package/src/lib/models.ts +138 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +5 -0
- package/src/orm/cli/makemigrations.ts +63 -0
- package/src/orm/cli/migrate.ts +127 -0
- package/src/orm/cli.ts +30 -0
- package/src/orm/core/base-model.ts +6 -0
- package/src/orm/core/manager.ts +27 -0
- package/src/orm/core/query-builder.ts +74 -0
- package/src/orm/db/connection.ts +0 -0
- package/src/orm/db/sql-types.ts +72 -0
- package/src/orm/db/sqlite.ts +4 -0
- package/src/orm/decorators/field.ts +80 -0
- package/src/orm/decorators/model.ts +36 -0
- package/src/orm/decorators/relations.ts +0 -0
- package/src/orm/metadata/field-metadata.ts +0 -0
- package/src/orm/metadata/field-types.ts +12 -0
- package/src/orm/metadata/get-meta.ts +9 -0
- package/src/orm/metadata/index.ts +15 -0
- package/src/orm/metadata/keys.ts +2 -0
- package/src/orm/metadata/model-registry.ts +53 -0
- package/src/orm/metadata/modifiers.ts +26 -0
- package/src/orm/metadata/types.ts +45 -0
- package/src/orm/migration-engine/diff.ts +243 -0
- package/src/orm/migration-engine/operations.ts +186 -0
- package/src/orm/schema/build.ts +138 -0
- package/src/orm/schema/state.ts +23 -0
- package/src/orm/schema/writeMigrations.ts +21 -0
- package/src/orm/syncdb.ts +25 -0
- package/src/pages/Dashboard.tsx +127 -0
- package/src/pages/Index.tsx +18 -0
- package/src/pages/ModelPage.tsx +177 -0
- package/src/pages/NotFound.tsx +24 -0
- package/src/pages/SchemaEditor.tsx +170 -0
- package/src/pages/Settings.tsx +166 -0
- package/src/server.ts +69 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +112 -0
- package/tailwind.config.ts +114 -0
- package/tsconfig.app.json +30 -0
- package/tsconfig.json +16 -0
- package/tsconfig.node.json +22 -0
- package/vite.config.js +23 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { NavLink, useLocation } from 'react-router-dom';
|
|
2
|
+
import { Database, LayoutDashboard, Users, FileText, Package, ShoppingCart, Settings, Code } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
5
|
+
Users,
|
|
6
|
+
FileText,
|
|
7
|
+
Package,
|
|
8
|
+
ShoppingCart,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function AdminSidebar({models}) {
|
|
12
|
+
const location = useLocation();
|
|
13
|
+
|
|
14
|
+
const isActive = (path: string) => location.pathname === path;
|
|
15
|
+
const isModelActive = (name: string) => location.pathname.startsWith(`/models/${name}`);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<aside className="w-64 min-h-screen bg-sidebar border-r border-sidebar-border flex flex-col">
|
|
19
|
+
{/* Logo */}
|
|
20
|
+
<div className="p-4 border-b border-sidebar-border">
|
|
21
|
+
<div className="flex items-center gap-3">
|
|
22
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
23
|
+
<Database className="w-5 h-5 text-primary" />
|
|
24
|
+
</div>
|
|
25
|
+
<div>
|
|
26
|
+
<h1 className="font-semibold text-foreground">DataForge</h1>
|
|
27
|
+
<p className="text-xs text-muted-foreground">ORM Admin Panel</p>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
{/* Navigation */}
|
|
33
|
+
<nav className="flex-1 p-3 space-y-1 scrollbar-thin overflow-y-auto">
|
|
34
|
+
<NavLink
|
|
35
|
+
to="/"
|
|
36
|
+
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
|
37
|
+
isActive('/')
|
|
38
|
+
? 'bg-sidebar-accent text-primary font-medium'
|
|
39
|
+
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'
|
|
40
|
+
}`}
|
|
41
|
+
>
|
|
42
|
+
<LayoutDashboard className="w-4 h-4" />
|
|
43
|
+
Dashboard
|
|
44
|
+
</NavLink>
|
|
45
|
+
|
|
46
|
+
<NavLink
|
|
47
|
+
to="/schema"
|
|
48
|
+
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
|
49
|
+
isActive('/schema')
|
|
50
|
+
? 'bg-sidebar-accent text-primary font-medium'
|
|
51
|
+
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'
|
|
52
|
+
}`}
|
|
53
|
+
>
|
|
54
|
+
<Code className="w-4 h-4" />
|
|
55
|
+
Schema Editor
|
|
56
|
+
</NavLink>
|
|
57
|
+
|
|
58
|
+
<div className="pt-4 pb-2">
|
|
59
|
+
<span className="px-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
60
|
+
Models
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{models.map((model) => {
|
|
65
|
+
const Icon = iconMap[model.icon] || Database;
|
|
66
|
+
return (
|
|
67
|
+
<NavLink
|
|
68
|
+
key={model.name}
|
|
69
|
+
to={`/models/${model.name}`}
|
|
70
|
+
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
|
71
|
+
isModelActive(model.name)
|
|
72
|
+
? 'bg-sidebar-accent text-primary font-medium'
|
|
73
|
+
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'
|
|
74
|
+
}`}
|
|
75
|
+
>
|
|
76
|
+
<Icon className="w-4 h-4" />
|
|
77
|
+
{model.displayName}
|
|
78
|
+
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
|
79
|
+
{model.fields.length}
|
|
80
|
+
</span>
|
|
81
|
+
</NavLink>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</nav>
|
|
85
|
+
|
|
86
|
+
{/* Footer */}
|
|
87
|
+
<div className="p-3 border-t border-sidebar-border">
|
|
88
|
+
<NavLink
|
|
89
|
+
to="/settings"
|
|
90
|
+
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
|
91
|
+
isActive('/settings')
|
|
92
|
+
? 'bg-sidebar-accent text-primary font-medium'
|
|
93
|
+
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'
|
|
94
|
+
}`}
|
|
95
|
+
>
|
|
96
|
+
<Settings className="w-4 h-4" />
|
|
97
|
+
Settings
|
|
98
|
+
</NavLink>
|
|
99
|
+
</div>
|
|
100
|
+
</aside>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ModelDefinition } from '@/lib/models';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import { Edit2, Trash2, Search, Plus } from 'lucide-react';
|
|
6
|
+
import {
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableRow,
|
|
13
|
+
} from '@/components/ui/table';
|
|
14
|
+
import { Badge } from '@/components/ui/badge';
|
|
15
|
+
import { ModelType } from '@/admin/types';
|
|
16
|
+
|
|
17
|
+
interface DataTableProps {
|
|
18
|
+
model: ModelType;
|
|
19
|
+
data: Record<string, unknown>[];
|
|
20
|
+
onEdit?: (record: Record<string, unknown>) => void;
|
|
21
|
+
onDelete?: (record: Record<string, unknown>) => void;
|
|
22
|
+
onAdd?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function DataTable({ model, data, onEdit, onDelete, onAdd }: DataTableProps) {
|
|
26
|
+
const [search, setSearch] = useState('');
|
|
27
|
+
const [sortField, setSortField] = useState<string | null>(null);
|
|
28
|
+
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
|
29
|
+
|
|
30
|
+
const filteredData = data.filter(record => {
|
|
31
|
+
if (!search) return true;
|
|
32
|
+
return Object.values(record).some(val =>
|
|
33
|
+
String(val).toLowerCase().includes(search.toLowerCase())
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const sortedData = sortField
|
|
38
|
+
? [...filteredData].sort((a, b) => {
|
|
39
|
+
const aVal = a[sortField];
|
|
40
|
+
const bVal = b[sortField];
|
|
41
|
+
if (aVal === bVal) return 0;
|
|
42
|
+
const comparison = aVal! < bVal! ? -1 : 1;
|
|
43
|
+
return sortDir === 'asc' ? comparison : -comparison;
|
|
44
|
+
})
|
|
45
|
+
: filteredData;
|
|
46
|
+
|
|
47
|
+
const handleSort = (field: string) => {
|
|
48
|
+
if (sortField === field) {
|
|
49
|
+
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
|
|
50
|
+
} else {
|
|
51
|
+
setSortField(field);
|
|
52
|
+
setSortDir('asc');
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const renderCellValue = (value: unknown, fieldType: string) => {
|
|
57
|
+
if (value === null || value === undefined) {
|
|
58
|
+
return <span className="text-muted-foreground">—</span>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof value === 'boolean') {
|
|
62
|
+
return (
|
|
63
|
+
<Badge variant={value ? 'default' : 'secondary'} className={value ? 'bg-success text-success-foreground' : ''}>
|
|
64
|
+
{value ? 'Yes' : 'No'}
|
|
65
|
+
</Badge>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (fieldType === 'select') {
|
|
70
|
+
return (
|
|
71
|
+
<Badge variant="outline" className="font-mono text-xs">
|
|
72
|
+
{String(value)}
|
|
73
|
+
</Badge>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return String(value);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="space-y-4">
|
|
82
|
+
<div className="flex items-center justify-between gap-4">
|
|
83
|
+
<div className="relative flex-1 max-w-sm">
|
|
84
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
85
|
+
<Input
|
|
86
|
+
placeholder="Search records..."
|
|
87
|
+
value={search}
|
|
88
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
89
|
+
className="pl-9 bg-secondary border-border"
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
<Button onClick={onAdd} className="gap-2">
|
|
93
|
+
<Plus className="w-4 h-4" />
|
|
94
|
+
Add {model.displayName}
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className="admin-card p-0 overflow-hidden">
|
|
99
|
+
<Table>
|
|
100
|
+
<TableHeader>
|
|
101
|
+
<TableRow className="border-border hover:bg-transparent">
|
|
102
|
+
{model.fields.map((field) => (
|
|
103
|
+
<TableHead
|
|
104
|
+
key={field.name}
|
|
105
|
+
onClick={() => handleSort(field.name)}
|
|
106
|
+
className="cursor-pointer hover:text-foreground transition-colors"
|
|
107
|
+
>
|
|
108
|
+
<span className="flex items-center gap-1">
|
|
109
|
+
{field.label}
|
|
110
|
+
{sortField === field.name && (
|
|
111
|
+
<span className="text-primary">
|
|
112
|
+
{sortDir === 'asc' ? '↑' : '↓'}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
</span>
|
|
116
|
+
</TableHead>
|
|
117
|
+
))}
|
|
118
|
+
<TableHead className="w-24">Actions</TableHead>
|
|
119
|
+
</TableRow>
|
|
120
|
+
</TableHeader>
|
|
121
|
+
<TableBody>
|
|
122
|
+
{sortedData.map((record, idx) => (
|
|
123
|
+
<TableRow key={idx} className="border-border hover:bg-secondary/50">
|
|
124
|
+
{model.fields.map((field) => (
|
|
125
|
+
<TableCell key={field.name} className="font-mono text-sm">
|
|
126
|
+
{renderCellValue(record[field.name], field.type)}
|
|
127
|
+
</TableCell>
|
|
128
|
+
))}
|
|
129
|
+
<TableCell>
|
|
130
|
+
<div className="flex items-center gap-1">
|
|
131
|
+
<Button
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="icon"
|
|
134
|
+
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
|
135
|
+
onClick={() => onEdit?.(record)}
|
|
136
|
+
>
|
|
137
|
+
<Edit2 className="w-4 h-4" />
|
|
138
|
+
</Button>
|
|
139
|
+
<Button
|
|
140
|
+
variant="ghost"
|
|
141
|
+
size="icon"
|
|
142
|
+
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
143
|
+
onClick={() => onDelete?.(record)}
|
|
144
|
+
>
|
|
145
|
+
<Trash2 className="w-4 h-4" />
|
|
146
|
+
</Button>
|
|
147
|
+
</div>
|
|
148
|
+
</TableCell>
|
|
149
|
+
</TableRow>
|
|
150
|
+
))}
|
|
151
|
+
</TableBody>
|
|
152
|
+
</Table>
|
|
153
|
+
|
|
154
|
+
{sortedData.length === 0 && (
|
|
155
|
+
<div className="p-8 text-center text-muted-foreground">
|
|
156
|
+
No records found
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div className="text-sm text-muted-foreground">
|
|
162
|
+
Showing {sortedData.length} of {data.length} records
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
import { Input } from '@/components/ui/input';
|
|
4
|
+
import { Label } from '@/components/ui/label';
|
|
5
|
+
import { Textarea } from '@/components/ui/textarea';
|
|
6
|
+
import { Switch } from '@/components/ui/switch';
|
|
7
|
+
import {
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectItem,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue,
|
|
13
|
+
} from '@/components/ui/select';
|
|
14
|
+
import {
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogContent,
|
|
17
|
+
DialogHeader,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
DialogFooter,
|
|
20
|
+
} from '@/components/ui/dialog';
|
|
21
|
+
import { Save, X } from 'lucide-react';
|
|
22
|
+
import { FieldMeta, ModelType } from '@/admin/types';
|
|
23
|
+
|
|
24
|
+
interface ModelFormProps {
|
|
25
|
+
model: ModelType;
|
|
26
|
+
initialData?: Record<string, unknown>;
|
|
27
|
+
open: boolean;
|
|
28
|
+
onClose: () => void;
|
|
29
|
+
onSave: (data: Record<string, unknown>) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ModelForm({ model, initialData, open, onClose, onSave }: ModelFormProps) {
|
|
33
|
+
const [formData, setFormData] = useState<Record<string, unknown>>(
|
|
34
|
+
initialData || model.fields.reduce((acc, field) => {
|
|
35
|
+
acc[field.name] = field.default ?? (field.type === 'boolean' ? false : '');
|
|
36
|
+
return acc;
|
|
37
|
+
}, {} as Record<string, unknown>)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const handleChange = (field: string, value: unknown) => {
|
|
41
|
+
setFormData(prev => ({ ...prev, [field]: value }));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
onSave(formData);
|
|
47
|
+
onClose();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!open) return;
|
|
52
|
+
if (!model) return;
|
|
53
|
+
|
|
54
|
+
if (initialData) {
|
|
55
|
+
setFormData(initialData);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const emptyData: Record<string, unknown> = {};
|
|
60
|
+
for (const field of model.fields) {
|
|
61
|
+
if (field.type === "primary") continue;
|
|
62
|
+
|
|
63
|
+
if (field.default !== undefined) {
|
|
64
|
+
emptyData[field.name] = field.default;
|
|
65
|
+
} else if (field.type === "boolean") {
|
|
66
|
+
emptyData[field.name] = false;
|
|
67
|
+
} else {
|
|
68
|
+
emptyData[field.name] = "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setFormData(emptyData);
|
|
73
|
+
}, [model, initialData, open]); // 🔑 open is REQUIRED
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
const renderField = (field: FieldMeta) => {
|
|
77
|
+
const value = formData[field.name];
|
|
78
|
+
switch (field.type) {
|
|
79
|
+
case 'boolean':
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex items-center justify-between">
|
|
82
|
+
<Label htmlFor={field.name} className="text-sm text-muted-foreground">
|
|
83
|
+
{field.label}
|
|
84
|
+
</Label>
|
|
85
|
+
<Switch
|
|
86
|
+
id={field.name}
|
|
87
|
+
checked={value as boolean}
|
|
88
|
+
onCheckedChange={(checked) => handleChange(field.name, checked)}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
case 'text':
|
|
94
|
+
return (
|
|
95
|
+
<div className="space-y-2">
|
|
96
|
+
<Label htmlFor={field.name} className="text-sm text-muted-foreground">
|
|
97
|
+
{field.label}
|
|
98
|
+
{field.required && <span className="text-destructive ml-1">*</span>}
|
|
99
|
+
</Label>
|
|
100
|
+
<Textarea
|
|
101
|
+
id={field.name}
|
|
102
|
+
value={value as string}
|
|
103
|
+
onChange={(e) => handleChange(field.name, e.target.value)}
|
|
104
|
+
className="bg-secondary border-border min-h-[100px]"
|
|
105
|
+
required={field.required}
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
case 'select':
|
|
111
|
+
return (
|
|
112
|
+
<div className="space-y-2">
|
|
113
|
+
<Label htmlFor={field.name} className="text-sm text-muted-foreground">
|
|
114
|
+
{field.label}
|
|
115
|
+
{field.required && <span className="text-destructive ml-1">*</span>}
|
|
116
|
+
</Label>
|
|
117
|
+
<Select
|
|
118
|
+
value={value as string}
|
|
119
|
+
onValueChange={(val) => handleChange(field.name, val)}
|
|
120
|
+
>
|
|
121
|
+
<SelectTrigger className="bg-secondary border-border">
|
|
122
|
+
<SelectValue placeholder={`Select ${field.label.toLowerCase()}`} />
|
|
123
|
+
</SelectTrigger>
|
|
124
|
+
<SelectContent>
|
|
125
|
+
{field.options?.map((option) => (
|
|
126
|
+
<SelectItem key={option} value={option}>
|
|
127
|
+
{option}
|
|
128
|
+
</SelectItem>
|
|
129
|
+
))}
|
|
130
|
+
</SelectContent>
|
|
131
|
+
</Select>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
case 'number':
|
|
136
|
+
return (
|
|
137
|
+
<div className="space-y-2">
|
|
138
|
+
<Label htmlFor={field.name} className="text-sm text-muted-foreground">
|
|
139
|
+
{field.label}
|
|
140
|
+
{field.required && <span className="text-destructive ml-1">*</span>}
|
|
141
|
+
</Label>
|
|
142
|
+
<Input
|
|
143
|
+
id={field.name}
|
|
144
|
+
type="number"
|
|
145
|
+
value={value as number}
|
|
146
|
+
onChange={(e) => handleChange(field.name, Number(e.target.value))}
|
|
147
|
+
className="bg-secondary border-border font-mono"
|
|
148
|
+
required={field.required}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
case 'datetime':
|
|
154
|
+
return (
|
|
155
|
+
<div className="space-y-2">
|
|
156
|
+
<Label htmlFor={field.name} className="text-sm text-muted-foreground">
|
|
157
|
+
{field.label}
|
|
158
|
+
{field.required && <span className="text-destructive ml-1">*</span>}
|
|
159
|
+
</Label>
|
|
160
|
+
<Input
|
|
161
|
+
id={field.name}
|
|
162
|
+
type="date"
|
|
163
|
+
value={value as string}
|
|
164
|
+
onChange={(e) => handleChange(field.name, e.target.value)}
|
|
165
|
+
className="bg-secondary border-border font-mono"
|
|
166
|
+
required={field.required}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
default:
|
|
172
|
+
return (
|
|
173
|
+
<div className="space-y-2">
|
|
174
|
+
<Label htmlFor={field.name} className="text-sm text-muted-foreground">
|
|
175
|
+
{field.label}
|
|
176
|
+
{field.required && <span className="text-destructive ml-1">*</span>}
|
|
177
|
+
</Label>
|
|
178
|
+
<Input
|
|
179
|
+
id={field.name}
|
|
180
|
+
type={field.type === 'email' ? 'email' : 'text'}
|
|
181
|
+
value={value as string}
|
|
182
|
+
onChange={(e) => handleChange(field.name, e.target.value)}
|
|
183
|
+
className="bg-secondary border-border"
|
|
184
|
+
required={field.required}
|
|
185
|
+
maxLength={field.maxLength}
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
return (
|
|
192
|
+
<Dialog open={open} onOpenChange={onClose}>
|
|
193
|
+
<DialogContent className="bg-card border-border max-w-lg">
|
|
194
|
+
<DialogHeader>
|
|
195
|
+
<DialogTitle className="flex items-center gap-2">
|
|
196
|
+
{initialData ? 'Edit' : 'Add'} {model.displayName}
|
|
197
|
+
</DialogTitle>
|
|
198
|
+
</DialogHeader>
|
|
199
|
+
|
|
200
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
201
|
+
{model.fields.filter(x=>x.options?.autoNowAdd !== true && !x.readonly).filter(x=>!x.primaryKey).map((field) => (
|
|
202
|
+
<div key={field.name}>
|
|
203
|
+
{renderField(field)}
|
|
204
|
+
</div>
|
|
205
|
+
))}
|
|
206
|
+
|
|
207
|
+
<DialogFooter className="gap-2">
|
|
208
|
+
<Button type="button" variant="outline" onClick={onClose} className="gap-2">
|
|
209
|
+
<X className="w-4 h-4" />
|
|
210
|
+
Cancel
|
|
211
|
+
</Button>
|
|
212
|
+
<Button type="submit" className="gap-2">
|
|
213
|
+
<Save className="w-4 h-4" />
|
|
214
|
+
Save
|
|
215
|
+
</Button>
|
|
216
|
+
</DialogFooter>
|
|
217
|
+
</form>
|
|
218
|
+
</DialogContent>
|
|
219
|
+
</Dialog>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
|
|
6
|
+
className?: string;
|
|
7
|
+
activeClassName?: string;
|
|
8
|
+
pendingClassName?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
|
12
|
+
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
|
|
13
|
+
return (
|
|
14
|
+
<RouterNavLink
|
|
15
|
+
ref={ref}
|
|
16
|
+
to={to}
|
|
17
|
+
className={({ isActive, isPending }) =>
|
|
18
|
+
cn(className, isActive && activeClassName, isPending && pendingClassName)
|
|
19
|
+
}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
NavLink.displayName = "NavLink";
|
|
27
|
+
|
|
28
|
+
export { NavLink };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface StatCardProps {
|
|
4
|
+
title: string;
|
|
5
|
+
value: string | number;
|
|
6
|
+
icon: ReactNode;
|
|
7
|
+
trend?: {
|
|
8
|
+
value: number;
|
|
9
|
+
isPositive: boolean;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function StatCard({ title, value, icon, trend }: StatCardProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="stat-card animate-fade-in">
|
|
16
|
+
<div className="flex items-start justify-between">
|
|
17
|
+
<div>
|
|
18
|
+
<p className="text-sm text-muted-foreground">{title}</p>
|
|
19
|
+
<p className="text-2xl font-semibold mt-1">{value}</p>
|
|
20
|
+
{trend && (
|
|
21
|
+
<p className={`text-xs mt-2 ${trend.isPositive ? 'text-success' : 'text-destructive'}`}>
|
|
22
|
+
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}% from last week
|
|
23
|
+
</p>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
|
27
|
+
{icon}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const Accordion = AccordionPrimitive.Root;
|
|
8
|
+
|
|
9
|
+
const AccordionItem = React.forwardRef<
|
|
10
|
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
11
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
12
|
+
>(({ className, ...props }, ref) => (
|
|
13
|
+
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
|
|
14
|
+
));
|
|
15
|
+
AccordionItem.displayName = "AccordionItem";
|
|
16
|
+
|
|
17
|
+
const AccordionTrigger = React.forwardRef<
|
|
18
|
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
19
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
20
|
+
>(({ className, children, ...props }, ref) => (
|
|
21
|
+
<AccordionPrimitive.Header className="flex">
|
|
22
|
+
<AccordionPrimitive.Trigger
|
|
23
|
+
ref={ref}
|
|
24
|
+
className={cn(
|
|
25
|
+
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
{...props}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
32
|
+
</AccordionPrimitive.Trigger>
|
|
33
|
+
</AccordionPrimitive.Header>
|
|
34
|
+
));
|
|
35
|
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
|
36
|
+
|
|
37
|
+
const AccordionContent = React.forwardRef<
|
|
38
|
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
39
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
40
|
+
>(({ className, children, ...props }, ref) => (
|
|
41
|
+
<AccordionPrimitive.Content
|
|
42
|
+
ref={ref}
|
|
43
|
+
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
|
47
|
+
</AccordionPrimitive.Content>
|
|
48
|
+
));
|
|
49
|
+
|
|
50
|
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
|
51
|
+
|
|
52
|
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|