@flaredev/cli 0.4.1 → 0.5.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/dist/index.js +685 -341
- package/package.json +2 -2
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/page.tsx +10 -0
- package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
- package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
- package/templates/app/components/dashboard/record-actions.tsx +7 -7
- package/templates/app/components/dashboard/record-detail.tsx +1 -1
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +129 -33
- package/templates/app/components/dashboard/resource-icon.tsx +87 -85
- package/templates/app/components/dashboard/resource-stats.tsx +10 -9
- package/templates/app/components/dashboard/resource-table.tsx +4 -4
- package/templates/app/components/dashboard/row-actions.tsx +3 -3
- package/templates/app/lib/costs.ts +138 -0
- package/templates/app/lib/dashboard-nav.ts +5 -1
- package/templates/app/lib/dashboard.ts +66 -61
- package/templates/app/lib/number.ts +33 -0
- package/templates/app/lib/site.ts +56 -56
- package/templates/app/lib/suggest.ts +35 -0
- package/templates/app/lib/usage.ts +32 -0
- package/templates/next/.env.example +21 -0
- package/templates/next/README.md +16 -0
- package/templates/next/app/api/health/route.ts +7 -0
- package/templates/next/app/dashboard/account/password/page.tsx +19 -0
- package/templates/next/app/dashboard/account/security/page.tsx +31 -0
- package/templates/next/app/dashboard/actions.ts +277 -0
- package/templates/next/app/dashboard/layout.tsx +64 -0
- package/templates/next/app/dashboard/page.tsx +127 -0
- package/templates/next/components/dashboard/resource-chart.tsx +86 -0
- package/templates/next/lib/analytics.ts +71 -0
- package/templates/next/lib/audit.ts +80 -0
- package/templates/next/lib/auth-emails.ts +92 -0
- package/templates/next/lib/auth.ts +156 -0
- package/templates/next/lib/dashboard.ts +161 -0
- package/templates/next/lib/db.ts +24 -0
- package/templates/next/lib/mail.ts +35 -0
- package/templates/next/lib/realtime.ts +23 -0
- package/templates/next/lib/redis.ts +17 -0
- package/templates/next/lib/storage.ts +57 -0
- package/templates/next/lib/theme.ts +83 -0
- package/templates/next/lib/views.ts +23 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/prisma/schema/base.prisma +162 -0
- package/templates/next/prisma.config.ts +15 -0
- package/templates/next/tsconfig.json +24 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
|
-
import { useMemo, useState, useTransition, type FormEvent } from "react";
|
|
6
|
-
import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource } from "@flaredev/core";
|
|
5
|
+
import { useMemo, useState, useTransition, type FormEvent, type MouseEvent } from "react";
|
|
6
|
+
import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
|
|
7
7
|
import { toast } from "sonner";
|
|
8
8
|
import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
|
|
9
9
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
@@ -11,6 +11,8 @@ import { Button } from "@/components/ui/button";
|
|
|
11
11
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
|
12
12
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
13
13
|
import { Spinner } from "@/components/ui/spinner";
|
|
14
|
+
import { canSuggest, suggestValue } from "@/lib/suggest";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
14
16
|
import { FieldWidget, type RelationMeta } from "./fields/field-widget";
|
|
15
17
|
|
|
16
18
|
export interface ResourceFormProps {
|
|
@@ -23,12 +25,33 @@ export interface ResourceFormProps {
|
|
|
23
25
|
relations?: Record<string, RelationMeta & { initialTitle?: string }>;
|
|
24
26
|
/** Where Cancel and a successful save go, when the form owns the page. */
|
|
25
27
|
listHref: string;
|
|
26
|
-
/** In a
|
|
28
|
+
/** In a sheet: close it instead of navigating, and drop the card around the fields. */
|
|
27
29
|
onDone?: () => void;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
const WIDE = new Set(["text", "file"]);
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* More fields than this and the form is split into steps.
|
|
36
|
+
*
|
|
37
|
+
* Six inputs in one column is where a form starts to read as a chore rather than a
|
|
38
|
+
* question, and a step at a time also means the first error appears before everything
|
|
39
|
+
* else has been filled in.
|
|
40
|
+
*/
|
|
41
|
+
const FIELDS_PER_STEP = 5;
|
|
42
|
+
|
|
43
|
+
type Entry = [string, StoredField & { label: string }];
|
|
44
|
+
|
|
45
|
+
/** Fields in declaration order, cut into steps of at most {@link FIELDS_PER_STEP}. */
|
|
46
|
+
function toSteps(fields: Entry[]): Entry[][] {
|
|
47
|
+
if (fields.length <= FIELDS_PER_STEP) return [fields];
|
|
48
|
+
const steps: Entry[][] = [];
|
|
49
|
+
for (let index = 0; index < fields.length; index += FIELDS_PER_STEP) {
|
|
50
|
+
steps.push(fields.slice(index, index + FIELDS_PER_STEP));
|
|
51
|
+
}
|
|
52
|
+
return steps;
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
/**
|
|
33
56
|
* Create/edit form rendered from field metadata. Values are validated in the browser
|
|
34
57
|
* with the descriptor's zod schemas, then again on the server by the same store the
|
|
@@ -38,28 +61,63 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
|
|
|
38
61
|
const router = useRouter();
|
|
39
62
|
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
40
63
|
const fields = useMemo(() => storedFields(resource), [resource]);
|
|
64
|
+
const steps = useMemo(() => toSteps(fields), [fields]);
|
|
65
|
+
const [step, setStep] = useState(0);
|
|
41
66
|
const [values, setValues] = useState(() => initialFormValues(resource, record));
|
|
42
67
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
43
68
|
const [pending, startTransition] = useTransition();
|
|
44
69
|
|
|
70
|
+
const last = step === steps.length - 1;
|
|
71
|
+
const visible = steps[step] ?? [];
|
|
72
|
+
|
|
45
73
|
const setValue = (key: string, value: string | boolean) => {
|
|
46
74
|
setValues((current) => ({ ...current, [key]: value }));
|
|
47
75
|
if (errors[key]) setErrors(({ [key]: _removed, ...rest }) => rest);
|
|
48
76
|
};
|
|
49
77
|
|
|
50
|
-
function focusFirstError(fieldErrors: Record<string, string
|
|
51
|
-
const first =
|
|
78
|
+
function focusFirstError(fieldErrors: Record<string, string>, within: Entry[] = fields) {
|
|
79
|
+
const first = within.find(([key]) => fieldErrors[key]);
|
|
52
80
|
if (first) document.getElementById(`field-${first[0]}`)?.focus();
|
|
53
81
|
}
|
|
54
82
|
|
|
83
|
+
/** Everything the descriptor objects to, whichever step it is on. */
|
|
84
|
+
function validate(): Record<string, string> {
|
|
85
|
+
const input = formValuesToInput(resource, values, mode);
|
|
86
|
+
const parsed = (mode === "create" ? validators.create : validators.update).safeParse(input);
|
|
87
|
+
return parsed.success ? {} : issuesByField(parsed.error.issues);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Move on only when this step is right; problems further on can wait their turn.
|
|
92
|
+
*
|
|
93
|
+
* The click is cancelled first. This button and the submit button on the last step are
|
|
94
|
+
* the same node to the browser, and React has already turned this one into the submit
|
|
95
|
+
* button by the time the click's default action runs — without this, going to the last
|
|
96
|
+
* step also saves the record.
|
|
97
|
+
*/
|
|
98
|
+
function next(event: MouseEvent<HTMLButtonElement>) {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
const found = validate();
|
|
101
|
+
const here = Object.fromEntries(Object.entries(found).filter(([key]) => visible.some(([field]) => field === key)));
|
|
102
|
+
if (Object.keys(here).length > 0) {
|
|
103
|
+
setErrors(here);
|
|
104
|
+
focusFirstError(here, visible);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
setErrors({});
|
|
108
|
+
setStep((current) => Math.min(current + 1, steps.length - 1));
|
|
109
|
+
}
|
|
110
|
+
|
|
55
111
|
function onSubmit(event: FormEvent) {
|
|
56
112
|
event.preventDefault();
|
|
57
113
|
const input = formValuesToInput(resource, values, mode);
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
114
|
+
const found = validate();
|
|
115
|
+
if (Object.keys(found).length > 0) {
|
|
116
|
+
setErrors(found);
|
|
117
|
+
// Send them to the step that holds the problem, not just to the field.
|
|
118
|
+
const broken = steps.findIndex((entries) => entries.some(([key]) => found[key]));
|
|
119
|
+
if (broken >= 0 && broken !== step) setStep(broken);
|
|
120
|
+
focusFirstError(found, broken >= 0 ? steps[broken]! : fields);
|
|
63
121
|
return;
|
|
64
122
|
}
|
|
65
123
|
|
|
@@ -74,17 +132,20 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
|
|
|
74
132
|
}
|
|
75
133
|
const fieldErrors = result.issues ? issuesByField(result.issues) : result.field ? { [result.field]: result.error } : { _form: result.error };
|
|
76
134
|
setErrors(fieldErrors);
|
|
77
|
-
|
|
135
|
+
const broken = steps.findIndex((entries) => entries.some(([key]) => fieldErrors[key]));
|
|
136
|
+
if (broken >= 0 && broken !== step) setStep(broken);
|
|
137
|
+
focusFirstError(fieldErrors, broken >= 0 ? steps[broken]! : fields);
|
|
78
138
|
});
|
|
79
139
|
}
|
|
80
140
|
|
|
81
|
-
const Frame = onDone ?
|
|
82
|
-
const Body = onDone ?
|
|
83
|
-
const Footer = onDone ?
|
|
141
|
+
const Frame = onDone ? SheetFrame : Card;
|
|
142
|
+
const Body = onDone ? SheetBody : CardContent;
|
|
143
|
+
const Footer = onDone ? SheetFooterRow : CardFooter;
|
|
84
144
|
|
|
85
145
|
return (
|
|
86
|
-
<form onSubmit={onSubmit} noValidate>
|
|
146
|
+
<form onSubmit={onSubmit} noValidate className={onDone ? "flex min-h-0 flex-1 flex-col" : undefined}>
|
|
87
147
|
<Frame>
|
|
148
|
+
{steps.length > 1 && <Steps count={steps.length} current={step} onGo={setStep} />}
|
|
88
149
|
<Body>
|
|
89
150
|
{errors._form && (
|
|
90
151
|
<Alert variant="destructive" className="mb-6">
|
|
@@ -92,16 +153,12 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
|
|
|
92
153
|
</Alert>
|
|
93
154
|
)}
|
|
94
155
|
<FieldGroup className="grid gap-6 md:grid-cols-2">
|
|
95
|
-
{
|
|
156
|
+
{visible.map(([key, def]) => {
|
|
96
157
|
const error = errors[key];
|
|
97
158
|
const widgetId = `field-${key}`;
|
|
98
159
|
const horizontal = def.kind === "boolean";
|
|
99
160
|
return (
|
|
100
|
-
<Field
|
|
101
|
-
key={key}
|
|
102
|
-
data-invalid={error ? true : undefined}
|
|
103
|
-
className={WIDE.has(def.kind) ? "md:col-span-2" : undefined}
|
|
104
|
-
>
|
|
161
|
+
<Field key={key} data-invalid={error ? true : undefined} className={WIDE.has(def.kind) ? "md:col-span-2" : undefined}>
|
|
105
162
|
<FieldLabel htmlFor={widgetId}>
|
|
106
163
|
{def.label}
|
|
107
164
|
{def.required && def.kind !== "boolean" && (
|
|
@@ -122,6 +179,7 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
|
|
|
122
179
|
invalid={Boolean(error)}
|
|
123
180
|
disabled={pending}
|
|
124
181
|
relation={relations[key]}
|
|
182
|
+
suggest={canSuggest(def) ? () => suggestValue(key, def, String(values[resource.titleField] ?? "")) : undefined}
|
|
125
183
|
/>
|
|
126
184
|
</div>
|
|
127
185
|
{def.helpText && <FieldDescription>{def.helpText}</FieldDescription>}
|
|
@@ -131,8 +189,12 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
|
|
|
131
189
|
})}
|
|
132
190
|
</FieldGroup>
|
|
133
191
|
</Body>
|
|
134
|
-
<Footer className="flex justify-end gap-2 border-t pt-4">
|
|
135
|
-
{
|
|
192
|
+
<Footer className="flex items-center justify-end gap-2 border-t pt-4">
|
|
193
|
+
{step > 0 ? (
|
|
194
|
+
<Button type="button" variant="outline" onClick={() => setStep((current) => current - 1)} disabled={pending}>
|
|
195
|
+
Back
|
|
196
|
+
</Button>
|
|
197
|
+
) : onDone ? (
|
|
136
198
|
<Button type="button" variant="outline" onClick={onDone} disabled={pending}>
|
|
137
199
|
Cancel
|
|
138
200
|
</Button>
|
|
@@ -141,25 +203,59 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
|
|
|
141
203
|
<Link href={listHref}>Cancel</Link>
|
|
142
204
|
</Button>
|
|
143
205
|
)}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
206
|
+
{last ? (
|
|
207
|
+
<Button key="submit" type="submit" disabled={pending}>
|
|
208
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
209
|
+
{mode === "create" ? `Create ${resource.label.toLowerCase()}` : "Save changes"}
|
|
210
|
+
</Button>
|
|
211
|
+
) : (
|
|
212
|
+
<Button key="next" type="button" onClick={next} disabled={pending}>
|
|
213
|
+
Next
|
|
214
|
+
</Button>
|
|
215
|
+
)}
|
|
148
216
|
</Footer>
|
|
149
217
|
</Frame>
|
|
150
218
|
</form>
|
|
151
219
|
);
|
|
152
220
|
}
|
|
153
221
|
|
|
154
|
-
/**
|
|
155
|
-
function
|
|
156
|
-
return
|
|
222
|
+
/** Where you are in a multi-step form, and a way back to a step already passed. */
|
|
223
|
+
function Steps({ count, current, onGo }: { count: number; current: number; onGo: (step: number) => void }) {
|
|
224
|
+
return (
|
|
225
|
+
<div className="flex items-center gap-3 pb-4">
|
|
226
|
+
<div className="flex flex-1 gap-1.5" role="presentation">
|
|
227
|
+
{Array.from({ length: count }, (_, index) => (
|
|
228
|
+
<button
|
|
229
|
+
key={index}
|
|
230
|
+
type="button"
|
|
231
|
+
aria-label={`Step ${index + 1} of ${count}`}
|
|
232
|
+
aria-current={index === current ? "step" : undefined}
|
|
233
|
+
disabled={index > current}
|
|
234
|
+
onClick={() => onGo(index)}
|
|
235
|
+
className={cn(
|
|
236
|
+
"h-1 flex-1 rounded-full transition-colors",
|
|
237
|
+
index <= current ? "bg-primary" : "bg-muted",
|
|
238
|
+
index < current && "cursor-pointer",
|
|
239
|
+
)}
|
|
240
|
+
/>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
244
|
+
Step {current + 1} of {count}
|
|
245
|
+
</span>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Plain wrappers so the same form works in a page (a card) and in a sheet (no card). */
|
|
251
|
+
function SheetFrame({ children }: { children: React.ReactNode }) {
|
|
252
|
+
return <div className="flex min-h-0 flex-1 flex-col gap-4">{children}</div>;
|
|
157
253
|
}
|
|
158
254
|
|
|
159
|
-
function
|
|
160
|
-
return <div className="
|
|
255
|
+
function SheetBody({ children }: { children: React.ReactNode }) {
|
|
256
|
+
return <div className="min-h-0 flex-1 overflow-y-auto px-1">{children}</div>;
|
|
161
257
|
}
|
|
162
258
|
|
|
163
|
-
function
|
|
259
|
+
function SheetFooterRow({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
164
260
|
return <div className={className}>{children}</div>;
|
|
165
261
|
}
|
|
@@ -1,85 +1,87 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BellIcon,
|
|
3
|
-
BookIcon,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
1
|
+
import {
|
|
2
|
+
BellIcon,
|
|
3
|
+
BookIcon,
|
|
4
|
+
WalletIcon,
|
|
5
|
+
BoxIcon,
|
|
6
|
+
BriefcaseIcon,
|
|
7
|
+
Building2Icon,
|
|
8
|
+
CalendarIcon,
|
|
9
|
+
ChartBarIcon,
|
|
10
|
+
ClipboardListIcon,
|
|
11
|
+
CreditCardIcon,
|
|
12
|
+
DatabaseIcon,
|
|
13
|
+
DollarSignIcon,
|
|
14
|
+
FileTextIcon,
|
|
15
|
+
FolderIcon,
|
|
16
|
+
GlobeIcon,
|
|
17
|
+
HeartIcon,
|
|
18
|
+
ImageIcon,
|
|
19
|
+
LayersIcon,
|
|
20
|
+
MailIcon,
|
|
21
|
+
MapPinIcon,
|
|
22
|
+
MessageSquareIcon,
|
|
23
|
+
PackageIcon,
|
|
24
|
+
PhoneIcon,
|
|
25
|
+
ReceiptIcon,
|
|
26
|
+
SettingsIcon,
|
|
27
|
+
ShieldAlertIcon,
|
|
28
|
+
RadioIcon,
|
|
29
|
+
ShieldIcon,
|
|
30
|
+
ShoppingCartIcon,
|
|
31
|
+
StarIcon,
|
|
32
|
+
TagIcon,
|
|
33
|
+
TicketIcon,
|
|
34
|
+
TruckIcon,
|
|
35
|
+
UserIcon,
|
|
36
|
+
UsersIcon,
|
|
37
|
+
WrenchIcon,
|
|
38
|
+
type LucideIcon,
|
|
39
|
+
} from "lucide-react";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Icons a resource descriptor can name (`icon: "users"`). Imported by name so unused
|
|
43
|
+
* ones are tree-shaken; add the lucide icons your app needs.
|
|
44
|
+
*/
|
|
45
|
+
export const RESOURCE_ICONS: Record<string, LucideIcon> = {
|
|
46
|
+
bell: BellIcon,
|
|
47
|
+
book: BookIcon,
|
|
48
|
+
wallet: WalletIcon,
|
|
49
|
+
box: BoxIcon,
|
|
50
|
+
briefcase: BriefcaseIcon,
|
|
51
|
+
building: Building2Icon,
|
|
52
|
+
calendar: CalendarIcon,
|
|
53
|
+
chart: ChartBarIcon,
|
|
54
|
+
clipboard: ClipboardListIcon,
|
|
55
|
+
"credit-card": CreditCardIcon,
|
|
56
|
+
database: DatabaseIcon,
|
|
57
|
+
dollar: DollarSignIcon,
|
|
58
|
+
file: FileTextIcon,
|
|
59
|
+
folder: FolderIcon,
|
|
60
|
+
globe: GlobeIcon,
|
|
61
|
+
heart: HeartIcon,
|
|
62
|
+
image: ImageIcon,
|
|
63
|
+
layers: LayersIcon,
|
|
64
|
+
mail: MailIcon,
|
|
65
|
+
"map-pin": MapPinIcon,
|
|
66
|
+
message: MessageSquareIcon,
|
|
67
|
+
package: PackageIcon,
|
|
68
|
+
phone: PhoneIcon,
|
|
69
|
+
receipt: ReceiptIcon,
|
|
70
|
+
settings: SettingsIcon,
|
|
71
|
+
radio: RadioIcon,
|
|
72
|
+
shield: ShieldIcon,
|
|
73
|
+
"shield-alert": ShieldAlertIcon,
|
|
74
|
+
cart: ShoppingCartIcon,
|
|
75
|
+
star: StarIcon,
|
|
76
|
+
tag: TagIcon,
|
|
77
|
+
ticket: TicketIcon,
|
|
78
|
+
truck: TruckIcon,
|
|
79
|
+
user: UserIcon,
|
|
80
|
+
users: UsersIcon,
|
|
81
|
+
wrench: WrenchIcon,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The icon for a resource, falling back to a generic document icon. */
|
|
85
|
+
export function resourceIcon(name: string | undefined): LucideIcon {
|
|
86
|
+
return (name && RESOURCE_ICONS[name]) || FileTextIcon;
|
|
87
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { storedFields, optionLabel, type Resource } from "@flaredev/core";
|
|
2
|
-
import {
|
|
2
|
+
import { resourceStats, trend } from "@/lib/dashboard";
|
|
3
3
|
import { resourceIcon } from "./resource-icon";
|
|
4
4
|
import { StatCards, type Stat } from "./stat-card";
|
|
5
5
|
|
|
@@ -11,7 +11,10 @@ import { StatCards, type Stat } from "./stat-card";
|
|
|
11
11
|
* in practice is the status of the thing — a deal's stage, an invoice's state.
|
|
12
12
|
*/
|
|
13
13
|
export async function ResourceStats({ resource }: { resource: Resource }) {
|
|
14
|
-
|
|
14
|
+
// The status split is asked for up front so the totals, the trend and the split all
|
|
15
|
+
// come from the one grouped query rather than three.
|
|
16
|
+
const statusField = storedFields(resource).find(([, def]) => def.kind === "enum" && def.filterable !== false);
|
|
17
|
+
const { total, current, previous, values } = await resourceStats(resource.name, statusField?.[0]);
|
|
15
18
|
|
|
16
19
|
const stats: Stat[] = [
|
|
17
20
|
{
|
|
@@ -21,20 +24,18 @@ export async function ResourceStats({ resource }: { resource: Resource }) {
|
|
|
21
24
|
},
|
|
22
25
|
{
|
|
23
26
|
label: "New this week",
|
|
24
|
-
value:
|
|
25
|
-
change: trend(
|
|
26
|
-
hint: `${
|
|
27
|
+
value: current,
|
|
28
|
+
change: trend(current, previous),
|
|
29
|
+
hint: `${previous.toLocaleString()} the week before`,
|
|
27
30
|
},
|
|
28
31
|
];
|
|
29
32
|
|
|
30
|
-
const statusField = storedFields(resource).find(([, def]) => def.kind === "enum" && def.filterable !== false);
|
|
31
33
|
if (statusField && total > 0) {
|
|
32
|
-
const [
|
|
34
|
+
const [, def] = statusField;
|
|
33
35
|
if (def.kind === "enum") {
|
|
34
|
-
const counts = await valueCounts(resource.name, key);
|
|
35
36
|
// Two options at most: a strip of eight cards is a wall, not a summary.
|
|
36
37
|
for (const option of def.options.slice(0, 2)) {
|
|
37
|
-
const value =
|
|
38
|
+
const value = values[option] ?? 0;
|
|
38
39
|
stats.push({
|
|
39
40
|
label: optionLabel(def, option),
|
|
40
41
|
value,
|
|
@@ -26,7 +26,7 @@ import type { RelationMeta } from "./fields/field-widget";
|
|
|
26
26
|
import { ImportDialog } from "./import-dialog";
|
|
27
27
|
import { LocalTime } from "./local-time";
|
|
28
28
|
import { hrefWith, toSearchParams, type SearchParams } from "./query";
|
|
29
|
-
import { NewRecordButton } from "./resource-form-
|
|
29
|
+
import { NewRecordButton } from "./resource-form-sheet";
|
|
30
30
|
import { ResourceTableToolbar } from "./resource-table-toolbar";
|
|
31
31
|
import { SaveViewButton } from "./save-view-button";
|
|
32
32
|
import { RowActions } from "./row-actions";
|
|
@@ -90,7 +90,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
90
90
|
relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
const
|
|
93
|
+
const overlayForms = site.dashboard.forms === "sheet";
|
|
94
94
|
const rowIds = rows.map((row) => String(row.id));
|
|
95
95
|
const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
|
|
96
96
|
|
|
@@ -112,7 +112,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
112
112
|
<ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
|
|
113
113
|
{permissions.create && <ImportDialog resource={forClient} />}
|
|
114
114
|
{permissions.create &&
|
|
115
|
-
(
|
|
115
|
+
(overlayForms ? (
|
|
116
116
|
<NewRecordButton resource={forClient} relations={relations} listHref={basePath} />
|
|
117
117
|
) : (
|
|
118
118
|
<Button asChild>
|
|
@@ -286,7 +286,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
286
286
|
<RowActions
|
|
287
287
|
resource={forClient}
|
|
288
288
|
id={id}
|
|
289
|
-
record={
|
|
289
|
+
record={overlayForms ? row : undefined}
|
|
290
290
|
relations={relations}
|
|
291
291
|
listHref={basePath}
|
|
292
292
|
editHref={resourcePath(resource, id, "edit")}
|
|
@@ -26,11 +26,11 @@ import {
|
|
|
26
26
|
DropdownMenuTrigger,
|
|
27
27
|
} from "@/components/ui/dropdown-menu";
|
|
28
28
|
import { Spinner } from "@/components/ui/spinner";
|
|
29
|
-
import {
|
|
29
|
+
import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Edit and delete for one row. Edit opens a dialog when the row's record came with it
|
|
33
|
-
* (site.dashboard.forms is "
|
|
33
|
+
* (site.dashboard.forms is "sheet"), and otherwise goes to the form page.
|
|
34
34
|
*/
|
|
35
35
|
export function RowActions({
|
|
36
36
|
resource,
|
|
@@ -124,7 +124,7 @@ export function RowActions({
|
|
|
124
124
|
</DropdownMenu>
|
|
125
125
|
|
|
126
126
|
{record && canUpdate && (
|
|
127
|
-
<
|
|
127
|
+
<ResourceFormSheet
|
|
128
128
|
resource={resource}
|
|
129
129
|
relations={relations}
|
|
130
130
|
listHref={listHref}
|