create-nevela 0.5.1 → 0.5.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/index.mjs CHANGED
@@ -361,6 +361,10 @@ async function inProject(name, args) {
361
361
  }
362
362
  if (name !== 'upgrade') process.exit(forward(project, name, args));
363
363
 
364
+ // The name first, before anything that waits on the network. A newer installer this
365
+ // one hands over to knows it has been shown, and prints one line in its place.
366
+ showTitle();
367
+
364
368
  // Upgrading is the one command that must not run on stale code itself: it is the newest
365
369
  // installer that knows what an older app needs repaired. So even a nevela command that
366
370
  // hasn't been updated in a while upgrades an app correctly.
@@ -371,9 +375,18 @@ async function inProject(name, args) {
371
375
  if (status !== null) process.exit(status);
372
376
  }
373
377
  }
374
- console.log(`\n ${indigo(bold('Nevela'))} ${dim('upgrade')}\n`);
378
+ const before = appVersion(project);
379
+ const looking = args.includes('--check') ? ' (checking only: nothing will be changed)' : '';
380
+ console.log(` ${indigo(`Nevela upgrade — this app: ${before ? `v${before}` : 'Nevela not installed in it yet'}`)}${dim(looking)}\n`);
375
381
  const releases = await latestRelease();
376
382
  const status = await upgrade(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
383
+ // Where it ended up, read from the app itself and not from what was asked for.
384
+ if (status === 0 && !args.includes('--check') && !args.includes('--undo')) {
385
+ const now = appVersion(project);
386
+ if (now) {
387
+ console.log(`\n ${green(`✔ ${now === before ? `This app is on Nevela v${now}, the version it was on.` : `This app is now on Nevela v${now}.`}`)}\n`);
388
+ }
389
+ }
377
390
  process.exit(status);
378
391
  }
379
392
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,6 +114,12 @@ return [
114
114
  'per_page' => 25,
115
115
  'max_per_page' => 100,
116
116
 
117
+ /*
118
+ | The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
119
+ | grid). They are saved in one transaction, so this is also how long one can run.
120
+ */
121
+ 'bulk_max' => 500,
122
+
117
123
  /*
118
124
  | File and image fields (image:image, manual:file(pdf)).
119
125
  |
@@ -382,6 +382,8 @@ final class ResourceGenerator
382
382
  foreach ($all as $d) {
383
383
  $controller = "\\App\\Http\\Controllers\\Api\\{$d->name}Controller::class";
384
384
  $lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
385
+ $lines[] = "Route::get('{$d->slug}/_insights', [{$controller}, 'insights'])->name('{$d->slug}.insights');";
386
+ $lines[] = "Route::post('{$d->slug}/_bulk', [{$controller}, 'bulk'])->name('{$d->slug}.bulk');";
385
387
  $lines[] = "Route::apiResource('{$d->slug}', {$controller});";
386
388
  }
387
389
  $body = implode("\n", $lines);
@@ -749,6 +751,13 @@ final class ResourceGenerator
749
751
  return Nevela::stats({$d->name}::query(), '{$d->name}', \$request);
750
752
  }
751
753
 
754
+ public function insights(Request \$request): JsonResponse
755
+ {
756
+ Gate::authorize('viewAny', {$d->name}::class);
757
+
758
+ return Nevela::insights({$d->name}::query(), '{$d->name}', \$request);
759
+ }
760
+
752
761
  public function store({$d->name}Request \$request): JsonResponse
753
762
  {
754
763
  \${$var} = {$d->name}::create(\$request->values());
@@ -759,6 +768,13 @@ final class ResourceGenerator
759
768
  ->header('Location', \$request->url().'/'.\${$var}->getKey());
760
769
  }
761
770
 
771
+ public function bulk(Request \$request): JsonResponse
772
+ {
773
+ Gate::authorize('create', {$d->name}::class);
774
+
775
+ return Nevela::createMany({$d->name}::class, {$d->name}Request::class, {$d->name}Resource::class, \$request);
776
+ }
777
+
762
778
  public function show({$d->name} \${$var}): {$d->name}Resource
763
779
  {
764
780
  Gate::authorize('view', \${$var});
@@ -3,6 +3,8 @@
3
3
  namespace Nevela\Laravel\Http;
4
4
 
5
5
  use Illuminate\Foundation\Http\FormRequest;
6
+ use Illuminate\Support\Facades\Validator as Validation;
7
+ use Illuminate\Validation\Rules\Unique;
6
8
  use Illuminate\Validation\Validator;
7
9
 
8
10
  /**
@@ -52,6 +54,79 @@ abstract class ResourceRequest extends FormRequest
52
54
  });
53
55
  }
54
56
 
57
+ /**
58
+ * Many rows, each checked by the rules for creating one.
59
+ *
60
+ * The same rules as a single create, on purpose: a second way into the table with its
61
+ * own idea of what is valid would let through a row the form then refuses to save.
62
+ * Rows are numbered from 1, as a person counts them.
63
+ *
64
+ * Two rows of one batch can't share a unique value either. The database would refuse
65
+ * the second, but only after the first was written and with no row number to show.
66
+ *
67
+ * @param list<mixed> $rows
68
+ * @return array{values: list<array<string, mixed>>, problems: list<array{row: int, issues: list<array{path: string, message: string}>}>}
69
+ */
70
+ public function many(array $rows): array
71
+ {
72
+ $rules = $this->rules();
73
+ $columns = $this->columns();
74
+ $known = array_keys($columns);
75
+ $computed = array_map(fn (string $field) => $field.'File', $known);
76
+ $unique = array_keys(array_filter($rules, fn ($rule) => is_array($rule) && array_filter($rule, fn ($one) => $one instanceof Unique) !== []));
77
+
78
+ $values = [];
79
+ $problems = [];
80
+ $taken = [];
81
+ foreach (array_values($rows) as $index => $row) {
82
+ $number = $index + 1;
83
+ if (! is_array($row) || array_is_list($row)) {
84
+ // An empty JSON object arrives as an empty list; anything else here is not fields at all.
85
+ $problems[] = ['row' => $number, 'issues' => [['path' => '', 'message' => $row === [] ? 'This row is empty.' : 'This row is not a record.']]];
86
+
87
+ continue;
88
+ }
89
+ $issues = [];
90
+ foreach (array_keys($row) as $key) {
91
+ if (! in_array($key, $known, true) && ! in_array($key, self::READ_ONLY, true) && ! in_array($key, $computed, true)) {
92
+ $issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
93
+ }
94
+ }
95
+ $validator = Validation::make($row, $rules);
96
+ if ($validator->fails()) {
97
+ array_push($issues, ...FlareErrors::issues($validator->errors()->toArray()));
98
+ }
99
+ foreach ($unique as $field) {
100
+ $value = $row[$field] ?? null;
101
+ if (! is_scalar($value) || (string) $value === '') {
102
+ continue;
103
+ }
104
+ $mark = $field."\0".$value;
105
+ if (isset($taken[$mark])) {
106
+ $issues[] = ['path' => $field, 'message' => "The same as row {$taken[$mark]}. Each one needs its own."];
107
+ } else {
108
+ $taken[$mark] = $number;
109
+ }
110
+ }
111
+ if ($issues !== []) {
112
+ $problems[] = ['row' => $number, 'issues' => $issues];
113
+
114
+ continue;
115
+ }
116
+
117
+ $validated = $validator->validated();
118
+ $mapped = [];
119
+ foreach ($columns as $field => $column) {
120
+ if (array_key_exists($field, $validated)) {
121
+ $mapped[$column] = $validated[$field];
122
+ }
123
+ }
124
+ $values[] = $mapped;
125
+ }
126
+
127
+ return ['values' => $values, 'problems' => $problems];
128
+ }
129
+
55
130
  /** Validated input keyed by column, ready for create()/update(). @return array<string, mixed> */
56
131
  public function values(): array
57
132
  {
@@ -3,11 +3,14 @@
3
3
  namespace Nevela\Laravel;
4
4
 
5
5
  use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Database\UniqueConstraintViolationException;
6
7
  use Illuminate\Http\JsonResponse;
7
8
  use Illuminate\Http\Request;
9
+ use Illuminate\Support\Facades\DB;
8
10
  use InvalidArgumentException;
9
11
  use Nevela\Laravel\Media\Uploads;
10
12
  use Nevela\Laravel\Support\Descriptor;
13
+ use Nevela\Laravel\Support\Insights;
11
14
  use Nevela\Laravel\Support\ListQuery;
12
15
  use Nevela\Laravel\Support\Naming;
13
16
 
@@ -18,7 +21,7 @@ use Nevela\Laravel\Support\Naming;
18
21
  final class Nevela
19
22
  {
20
23
  /** Written by `pnpm release`; the same number as the repository's git tag. */
21
- public const VERSION = '0.5.1';
24
+ public const VERSION = '0.5.2';
22
25
 
23
26
  /** @var array<string, Descriptor> */
24
27
  private static array $descriptors = [];
@@ -79,6 +82,35 @@ final class Nevela
79
82
  return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
80
83
  }
81
84
 
85
+ self::narrow($query, $resource, $list);
86
+
87
+ $query->reorder()
88
+ ->orderBy($list->sort['column'], $list->sort['direction'])
89
+ ->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
90
+
91
+ $page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
92
+
93
+ // One query for every file on the page, instead of one per record.
94
+ foreach ($resource->fields as $field) {
95
+ if ($field->kind === 'file') {
96
+ Uploads::preload($page->getCollection()->pluck($field->column()));
97
+ }
98
+ }
99
+
100
+ return response()->json([
101
+ 'data' => $resourceClass::collection($page->getCollection())->resolve($request),
102
+ 'meta' => [
103
+ 'page' => $page->currentPage(),
104
+ 'perPage' => $page->perPage(),
105
+ 'total' => $page->total(),
106
+ 'totalPages' => max(1, $page->lastPage()),
107
+ ],
108
+ ]);
109
+ }
110
+
111
+ /** The search and the filters of a list request, applied. Its insights are narrowed the same way. */
112
+ private static function narrow(Builder $query, Descriptor $resource, ListQuery $list): void
113
+ {
82
114
  if ($list->q !== null) {
83
115
  $columns = array_map(fn ($f) => $f->column(), array_values(array_filter($resource->fields, fn ($f) => $f->searchable())));
84
116
  if ($columns !== []) {
@@ -96,31 +128,146 @@ final class Nevela
96
128
  ? $query->whereNull($filter['column'])
97
129
  : $query->where($filter['column'], $filter['value']);
98
130
  }
131
+ }
99
132
 
100
- $query->reorder()
101
- ->orderBy($list->sort['column'], $list->sort['direction'])
102
- ->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
133
+ /**
134
+ * GET /{slug}/_insights?unit=day|week|month&q&filter[field] → { total, unit, series, breakdown }.
135
+ *
136
+ * What the dashboard's insights panel draws: how many records were created in each
137
+ * period, and how they split across each field that is a choice (an enum or a yes/no).
138
+ *
139
+ * It takes the list's own search and filters, so the charts describe the rows the
140
+ * table is showing. A whole-table chart above a filtered table would be wrong in a
141
+ * prominent place.
142
+ */
143
+ public static function insights(Builder $query, string $name, Request $request): JsonResponse
144
+ {
145
+ $resource = self::resource($name);
146
+ $unit = $request->query('unit', 'day');
147
+ if (! is_string($unit) || ! isset(Insights::PERIODS[$unit])) {
148
+ return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'unit', 'message' => 'Must be day, week or month.']]], 400);
149
+ }
150
+ // The same parameters as the list. Where in the list someone is makes no difference to a count.
151
+ $params = array_diff_key($request->query(), array_flip(['unit', 'page', 'perPage', 'sort', 'columns']));
152
+ $list = ListQuery::parse($resource, $params, (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
153
+ if (! $list->ok()) {
154
+ return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
155
+ }
156
+ self::narrow($query, $resource, $list);
157
+ $base = fn () => (clone $query)->reorder();
103
158
 
104
- $page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
159
+ // Counted per day by the database, one grouped query over the created_at index,
160
+ // and added up into weeks or months here: see Support\Insights.
161
+ $now = now();
162
+ $table = $query->getModel()->getTable();
163
+ $day = $query->getConnection()->getDriverName() === 'sqlsrv' ? "CAST({$table}.created_at AS date)" : "DATE({$table}.created_at)";
164
+ $perDay = $base()->toBase()
165
+ ->where("{$table}.created_at", '>=', Insights::since($unit, $now))
166
+ ->selectRaw("{$day} as day, count(*) as aggregate")
167
+ ->groupByRaw($day)
168
+ ->get()
169
+ ->map(fn ($row) => [(string) $row->day, (int) $row->aggregate]);
105
170
 
106
- // One query for every file on the page, instead of one per record.
171
+ $breakdown = [];
107
172
  foreach ($resource->fields as $field) {
108
- if ($field->kind === 'file') {
109
- Uploads::preload($page->getCollection()->pluck($field->column()));
173
+ if (! in_array($field->kind, ['enum', 'boolean'], true)) {
174
+ continue;
175
+ }
176
+ if (count($breakdown) === 4) {
177
+ break; // one grouped query each, and four charts is already a lot to read
110
178
  }
179
+ $column = $field->column();
180
+ $counts = [];
181
+ foreach ($base()->toBase()->select($column.' as value')->selectRaw('count(*) as aggregate')->groupBy($column)->get() as $row) {
182
+ $value = $row->value === null ? '' : ($field->kind === 'boolean' ? ($row->value ? 'true' : 'false') : (string) $row->value);
183
+ $counts[$value] = ($counts[$value] ?? 0) + (int) $row->aggregate;
184
+ }
185
+ // Every choice the field has, in its own order, so one nobody has picked shows as zero.
186
+ $values = $field->kind === 'boolean' ? ['true', 'false'] : $field->options;
187
+ $slices = array_map(fn (string $value) => ['value' => $value, 'count' => $counts[$value] ?? 0], $values);
188
+ if (($counts[''] ?? 0) > 0) {
189
+ $slices[] = ['value' => '', 'count' => $counts['']];
190
+ }
191
+ $breakdown[] = ['field' => $field->name, 'kind' => $field->kind, 'slices' => $slices];
111
192
  }
112
193
 
113
194
  return response()->json([
114
- 'data' => $resourceClass::collection($page->getCollection())->resolve($request),
115
- 'meta' => [
116
- 'page' => $page->currentPage(),
117
- 'perPage' => $page->perPage(),
118
- 'total' => $page->total(),
119
- 'totalPages' => max(1, $page->lastPage()),
120
- ],
195
+ 'total' => $base()->count(),
196
+ 'unit' => $unit,
197
+ 'series' => Insights::series($unit, $now, $perDay),
198
+ 'breakdown' => $breakdown,
121
199
  ]);
122
200
  }
123
201
 
202
+ /**
203
+ * POST /{slug}/_bulk { items: [ … ] } → 201 { created, data }, or 422 { error, rows }.
204
+ *
205
+ * Several records in one go, for the dashboard's grid. Every row is checked before
206
+ * anything is written, and they are saved in one transaction: a mistake on row 7
207
+ * is reported against row 7 and nothing is created, where saving six and stopping
208
+ * would leave someone working out which six.
209
+ *
210
+ * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass
211
+ * @param class-string<Http\ResourceRequest> $requestClass
212
+ * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
213
+ */
214
+ public static function createMany(string $modelClass, string $requestClass, string $resourceClass, Request $request): JsonResponse
215
+ {
216
+ $most = max(1, (int) config('nevela.bulk_max', 500));
217
+ $items = $request->input('items');
218
+ if (! is_array($items) || ! array_is_list($items) || $items === []) {
219
+ return response()->json(['error' => 'Send the rows to create as "items", a list with at least one row.'], 422);
220
+ }
221
+ if (count($items) > $most) {
222
+ return response()->json(['error' => 'That is '.number_format(count($items))." rows. At most {$most} can be created at once; for more, import a file."], 422);
223
+ }
224
+
225
+ $checked = (new $requestClass)->many($items);
226
+ if ($checked['problems'] !== []) {
227
+ return self::rowsRefused($checked['problems'], count($items));
228
+ }
229
+
230
+ try {
231
+ $created = DB::transaction(function () use ($modelClass, $checked) {
232
+ $created = [];
233
+ foreach ($checked['values'] as $index => $values) {
234
+ try {
235
+ $created[] = $modelClass::create($values);
236
+ } catch (UniqueConstraintViolationException $e) {
237
+ // Someone else created the same value between the check and the write.
238
+ throw new \RuntimeException((string) ($index + 1), 0, $e);
239
+ }
240
+ }
241
+
242
+ return $created;
243
+ });
244
+ } catch (\RuntimeException $e) {
245
+ if (! $e->getPrevious() instanceof UniqueConstraintViolationException) {
246
+ throw $e;
247
+ }
248
+
249
+ return self::rowsRefused([['row' => (int) $e->getMessage(), 'issues' => [['path' => '', 'message' => 'A record with one of these values was created a moment ago.']]]], count($items));
250
+ }
251
+
252
+ return response()->json([
253
+ 'created' => count($created),
254
+ 'data' => array_map(fn ($model) => (new $resourceClass($model->refresh()))->resolve($request), $created),
255
+ ], 201);
256
+ }
257
+
258
+ /** @param list<array{row: int, issues: list<array{path: string, message: string}>}> $problems */
259
+ private static function rowsRefused(array $problems, int $sent): JsonResponse
260
+ {
261
+ $wrong = count($problems);
262
+
263
+ return response()->json([
264
+ 'error' => $wrong === 1
265
+ ? "Nothing was created: row {$problems[0]['row']} needs fixing."
266
+ : "Nothing was created: {$wrong} of the {$sent} rows need fixing.",
267
+ 'rows' => $problems,
268
+ ], 422);
269
+ }
270
+
124
271
  /**
125
272
  * GET /{slug}/_stats?field=status&days=7 → { total, current, previous, values }.
126
273
  * What Flare's ResourceStats card shows above a table.
@@ -0,0 +1,78 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Carbon\CarbonImmutable;
6
+ use Carbon\CarbonInterface;
7
+
8
+ /**
9
+ * The periods a "created per day, week or month" chart is drawn in.
10
+ *
11
+ * The database is only ever asked for counts per day, which every database can give
12
+ * with the same expression. Weeks and months are added up here, so a week starts on
13
+ * Monday and a month is a calendar month whichever database is underneath.
14
+ */
15
+ final class Insights
16
+ {
17
+ /** How many periods of each kind a chart shows: a month of days, half a year of weeks, a year of months. */
18
+ public const PERIODS = ['day' => 30, 'week' => 26, 'month' => 12];
19
+
20
+ /**
21
+ * The periods up to and including the one `$now` is in, oldest first.
22
+ *
23
+ * @return list<string> A day or a week as "2026-10-05" (a week by its Monday), a month as "2026-10"
24
+ */
25
+ public static function buckets(string $unit, CarbonInterface $now): array
26
+ {
27
+ $current = CarbonImmutable::instance($now)->startOfDay();
28
+ $buckets = [];
29
+ for ($back = self::PERIODS[$unit] - 1; $back >= 0; $back--) {
30
+ $buckets[] = match ($unit) {
31
+ 'day' => $current->subDays($back)->format('Y-m-d'),
32
+ 'week' => $current->startOfWeek(CarbonInterface::MONDAY)->subWeeks($back)->format('Y-m-d'),
33
+ // From the first of the month: stepping back from the 31st would skip the short months.
34
+ 'month' => $current->startOfMonth()->subMonthsNoOverflow($back)->format('Y-m'),
35
+ };
36
+ }
37
+
38
+ return $buckets;
39
+ }
40
+
41
+ /** The first moment the chart covers. */
42
+ public static function since(string $unit, CarbonInterface $now): CarbonImmutable
43
+ {
44
+ $first = self::buckets($unit, $now)[0];
45
+
46
+ return CarbonImmutable::parse($unit === 'month' ? "{$first}-01" : $first, $now->getTimezone())->startOfDay();
47
+ }
48
+
49
+ /** Which period a day ("2026-10-09") belongs to. */
50
+ public static function bucketOf(string $unit, string $day): string
51
+ {
52
+ return match ($unit) {
53
+ 'day' => substr($day, 0, 10),
54
+ 'week' => CarbonImmutable::parse(substr($day, 0, 10))->startOfWeek(CarbonInterface::MONDAY)->format('Y-m-d'),
55
+ 'month' => substr($day, 0, 7),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Counts per day, added up into the chart's periods. A period with nothing in it is
61
+ * still there, as zero: a gap in a chart should look like a gap.
62
+ *
63
+ * @param iterable<array{0: string, 1: int}> $perDay Pairs of day and count
64
+ * @return list<array{bucket: string, count: int}>
65
+ */
66
+ public static function series(string $unit, CarbonInterface $now, iterable $perDay): array
67
+ {
68
+ $counts = array_fill_keys(self::buckets($unit, $now), 0);
69
+ foreach ($perDay as [$day, $count]) {
70
+ $bucket = self::bucketOf($unit, $day);
71
+ if (isset($counts[$bucket])) {
72
+ $counts[$bucket] += $count;
73
+ }
74
+ }
75
+
76
+ return array_map(fn (string $bucket, int $count) => ['bucket' => $bucket, 'count' => $count], array_keys($counts), array_values($counts));
77
+ }
78
+ }
@@ -4,7 +4,9 @@ import { revalidatePath } from "next/cache";
4
4
  import { storedFields, type PolicyAction } from "@flaredev/core";
5
5
  import type { FieldIssue } from "@/lib/resource/store";
6
6
  import { toCsv } from "@/lib/csv";
7
+ import { revalidateResource } from "@/lib/cache";
7
8
  import { fileUrl } from "@/lib/files";
9
+ import { laravel } from "@/lib/laravel";
8
10
  import { allResources, resourcePath, dashboardSession, dashboardStore, mayDo } from "@/lib/dashboard";
9
11
 
10
12
  export type ActionResult<T = unknown> =
@@ -103,6 +105,83 @@ export async function createRecordAction(resourceName: string, input: unknown):
103
105
  return result;
104
106
  }
105
107
 
108
+ export type InsightsUnit = "day" | "week" | "month";
109
+
110
+ export interface InsightsData {
111
+ /** Everything the list matches, whenever it was created. */
112
+ total: number;
113
+ unit: InsightsUnit;
114
+ /** How many were created in each period, oldest first. A day or a week is "2026-10-05" (a week by its Monday), a month "2026-10". */
115
+ series: { bucket: string; count: number }[];
116
+ /** How the records split across each field that is a choice. */
117
+ breakdown: { field: string; kind: "enum" | "boolean"; slices: { value: string; count: number }[] }[];
118
+ }
119
+
120
+ /**
121
+ * The counts behind the insights panel: `GET /{slug}/_insights`.
122
+ *
123
+ * `query` is the list's own search and filters, so the charts are of the rows the table
124
+ * is showing. Asked for only when the panel is opened.
125
+ */
126
+ export async function resourceInsightsAction(resourceName: string, query: string, unit: InsightsUnit): Promise<ActionResult<InsightsData>> {
127
+ const denied = await allowed(resourceName, "read");
128
+ if (denied) return denied;
129
+
130
+ const { resource } = dashboardStore(resourceName);
131
+ const params = new URLSearchParams();
132
+ for (const [key, value] of new URLSearchParams(query)) if (key === "q" || key.startsWith("filter[")) params.append(key, value);
133
+ params.set("unit", unit);
134
+ const response = await laravel(`${resource.slug}/_insights?${params}`);
135
+ if (!response.ok) {
136
+ const body = (response.body ?? {}) as { error?: string };
137
+ // An app whose Laravel side is from before this existed has no such address.
138
+ const missing = response.status === 404 || response.status === 405;
139
+ return { ok: false, status: response.status, error: missing ? "This app's API has no insights yet. Run: nevela upgrade" : (body.error ?? "Request failed.") };
140
+ }
141
+ return { ok: true, data: response.body as InsightsData };
142
+ }
143
+
144
+ /** What was wrong with one row of several, numbered from 1 among the rows that were sent. */
145
+ export interface RowProblem {
146
+ row: number;
147
+ issues: FieldIssue[];
148
+ }
149
+
150
+ export type CreateManyResult = { ok: true; data: { created: number } } | { ok: false; status: number; error: string; rows?: RowProblem[] };
151
+
152
+ /**
153
+ * Several records at once, for the "Add several" grid: `POST /{slug}/_bulk`.
154
+ *
155
+ * Laravel checks every row with the rules it uses for one, and saves them in a single
156
+ * transaction. So the answer is all of them, or none and the rows that were wrong.
157
+ */
158
+ export async function createManyAction(resourceName: string, rows: Record<string, unknown>[]): Promise<CreateManyResult> {
159
+ const denied = await allowed(resourceName, "create");
160
+ if (denied) return denied;
161
+ if (rows.length === 0) return { ok: false, status: 400, error: "Nothing to create: every row is empty." };
162
+
163
+ const { resource } = dashboardStore(resourceName);
164
+ const response = await laravel(`${resource.slug}/_bulk`, { method: "POST", body: { items: rows } });
165
+ const body = (response.body ?? {}) as { error?: string; rows?: RowProblem[]; created?: number; data?: { id?: unknown }[] };
166
+ if (!response.ok) {
167
+ return {
168
+ ok: false,
169
+ status: response.status,
170
+ // An app whose Laravel side is from before this existed answers 404 or 405.
171
+ error: response.status === 404 || response.status === 405 ? "This app's API can't create several at once yet. Run: nevela upgrade" : (body.error ?? "Request failed."),
172
+ ...(body.rows ? { rows: body.rows } : {}),
173
+ };
174
+ }
175
+
176
+ try {
177
+ revalidateResource({ resource: resource.name, action: "create", id: String(body.data?.[0]?.id ?? "") });
178
+ } catch (error) {
179
+ console.error(`[nevela] ${resource.name} bulk create cache invalidation failed:`, error);
180
+ }
181
+ revalidatePath(resourcePath(resource));
182
+ return { ok: true, data: { created: body.created ?? rows.length } };
183
+ }
184
+
106
185
  export async function updateRecordAction(resourceName: string, id: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
107
186
  const denied = await allowed(resourceName, "update");
108
187
  if (denied) return denied;
@@ -0,0 +1,326 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useMemo, useRef, useState, useTransition } from "react";
5
+ import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
6
+ import { ListPlusIcon, PlusIcon, XIcon } from "lucide-react";
7
+ import { toast } from "sonner";
8
+ import { createManyAction } from "@/app/dashboard/actions";
9
+ import { Alert, AlertDescription } from "@/components/ui/alert";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Checkbox } from "@/components/ui/checkbox";
12
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
13
+ import { Input } from "@/components/ui/input";
14
+ import { Spinner } from "@/components/ui/spinner";
15
+ import { cn } from "@/lib/utils";
16
+ import { FieldWidget, type RelationMeta } from "./fields/field-widget";
17
+
18
+ type FieldDef = StoredField & { label: string };
19
+ type Values = Record<string, string | boolean>;
20
+ type Relations = Record<string, RelationMeta & { initialTitle?: string }>;
21
+
22
+ interface Row {
23
+ /** Stays with the row as others are added and removed around it. */
24
+ key: number;
25
+ values: Values;
26
+ }
27
+
28
+ /**
29
+ * Enough to see that more can be added, and few enough not to look like a form to fill
30
+ * in. An empty grid gives nothing to type into; one row means clicking "Add row" before
31
+ * you have begun.
32
+ */
33
+ const STARTING_ROWS = 5;
34
+
35
+ /**
36
+ * Where the grid stops adding rows: what Laravel accepts in one request unless an app
37
+ * changes it (config/nevela.php, bulk_max). An app that sets it lower is still safe:
38
+ * Laravel refuses the larger request, and its message, which says the limit, is shown.
39
+ */
40
+ const MOST_ROWS = 500;
41
+
42
+ /** Kinds that fit in a cell: one control, one value. A file needs its own upload, so it is left to the form. */
43
+ const IN_A_CELL = new Set(["string", "text", "int", "float", "boolean", "date", "datetime", "enum", "belongsTo"]);
44
+
45
+ /**
46
+ * Add several records at once, in a grid.
47
+ *
48
+ * Between "New" and "Import". Import is for a file somebody already has, and New is for
49
+ * one record. This is for the twelve you have in your head or on a piece of paper, which
50
+ * until now meant opening the form twelve times.
51
+ *
52
+ * Rows left empty are ignored, so nobody has to delete the ones they didn't use. The rows
53
+ * that were filled in are saved together or not at all: Laravel checks every one first,
54
+ * and a mistake comes back against its row with nothing created.
55
+ */
56
+ export function BulkCreateButton({ resource, relations = {} }: { resource: ClientResource; relations?: Relations }) {
57
+ const [open, setOpen] = useState(false);
58
+ const plural = resource.pluralLabel.toLowerCase();
59
+
60
+ return (
61
+ <Dialog open={open} onOpenChange={setOpen}>
62
+ <DialogTrigger asChild>
63
+ <Button variant="outline">
64
+ <ListPlusIcon data-icon="inline-start" />
65
+ Add several
66
+ </Button>
67
+ </DialogTrigger>
68
+ <DialogContent className="flex max-h-[90vh] flex-col gap-4 sm:max-w-[min(96vw,84rem)]">
69
+ <DialogHeader>
70
+ <DialogTitle>Add several {plural}</DialogTitle>
71
+ <DialogDescription>One row for each. Rows you leave empty are ignored, and they are all saved together or not at all.</DialogDescription>
72
+ </DialogHeader>
73
+ {/* Mounted only while open, so what was typed and abandoned last time is gone. */}
74
+ {open && <Grid resource={resource} relations={relations} onDone={() => setOpen(false)} />}
75
+ </DialogContent>
76
+ </Dialog>
77
+ );
78
+ }
79
+
80
+ function Grid({ resource, relations, onDone }: { resource: ClientResource; relations: Relations; onDone: () => void }) {
81
+ const router = useRouter();
82
+ const [pending, startTransition] = useTransition();
83
+ const validators = useMemo(() => createValidators(resource), [resource]);
84
+ const all = useMemo(() => storedFields(resource) as [string, FieldDef][], [resource]);
85
+ const fields = useMemo(() => all.filter(([, def]) => IN_A_CELL.has(def.kind)), [all]);
86
+ // A field this grid can't show and Laravel requires would fail every row, with nothing
87
+ // on screen to fix. Say so, and leave those records to the form.
88
+ const missing = useMemo(() => all.filter(([, def]) => !IN_A_CELL.has(def.kind) && def.required !== false), [all]);
89
+
90
+ const blank = useMemo(() => initialFormValues(resource, null) as Values, [resource]);
91
+ const nextKey = useRef(STARTING_ROWS);
92
+ const [rows, setRows] = useState<Row[]>(() => Array.from({ length: STARTING_ROWS }, (_, key) => ({ key, values: { ...blank } })));
93
+ /** Problems by row key, then by field. "" holds what isn't about one field. */
94
+ const [problems, setProblems] = useState<Record<number, Record<string, string>>>({});
95
+ const [error, setError] = useState<string | null>(null);
96
+
97
+ // A row is empty when it is exactly as it started: a tick box left unticked is not an entry.
98
+ const isEmpty = (values: Values) => fields.every(([key]) => values[key] === blank[key]);
99
+ const filled = rows.filter((row) => !isEmpty(row.values));
100
+ const noun = filled.length === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase();
101
+
102
+ function change(rowKey: number, field: string, value: string | boolean) {
103
+ setRows((current) => current.map((row) => (row.key === rowKey ? { ...row, values: { ...row.values, [field]: value } } : row)));
104
+ if (problems[rowKey]?.[field]) {
105
+ setProblems(({ [rowKey]: row = {}, ...rest }) => {
106
+ const { [field]: _fixed, ...others } = row;
107
+ return Object.keys(others).length > 0 ? { ...rest, [rowKey]: others } : rest;
108
+ });
109
+ }
110
+ }
111
+
112
+ function addRows(count: number) {
113
+ setRows((current) => [...current, ...Array.from({ length: Math.min(count, MOST_ROWS - current.length) }, () => ({ key: nextKey.current++, values: { ...blank } }))]);
114
+ }
115
+
116
+ function removeRow(rowKey: number) {
117
+ setRows((current) => (current.length > 1 ? current.filter((row) => row.key !== rowKey) : [{ key: nextKey.current++, values: { ...blank } }]));
118
+ setProblems(({ [rowKey]: _gone, ...rest }) => rest);
119
+ }
120
+
121
+ function save() {
122
+ setError(null);
123
+ if (filled.length === 0) {
124
+ setError("Nothing to create yet: every row is empty.");
125
+ return;
126
+ }
127
+ // Checked here first with the descriptor's own rules, so most mistakes never leave the page.
128
+ const found: Record<number, Record<string, string>> = {};
129
+ const inputs = filled.map((row) => {
130
+ const input = formValuesToInput(resource, row.values, "create") as Record<string, unknown>;
131
+ const parsed = validators.create.safeParse(input);
132
+ if (!parsed.success) {
133
+ const visible = Object.fromEntries(Object.entries(issuesByField(parsed.error.issues)).filter(([key]) => fields.some(([field]) => field === key)));
134
+ if (Object.keys(visible).length > 0) found[row.key] = visible;
135
+ }
136
+ // Only what the grid shows is sent; a field it can't show is left for Laravel to judge.
137
+ return Object.fromEntries(Object.entries(input).filter(([key]) => fields.some(([field]) => field === key)));
138
+ });
139
+ if (Object.keys(found).length > 0) {
140
+ setProblems(found);
141
+ setError(Object.keys(found).length === 1 ? "One row needs fixing." : `${Object.keys(found).length} rows need fixing.`);
142
+ return;
143
+ }
144
+
145
+ startTransition(async () => {
146
+ const result = await createManyAction(resource.name, inputs);
147
+ if (result.ok) {
148
+ toast.success(`${result.data.created} ${result.data.created === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()} created.`);
149
+ onDone();
150
+ router.refresh();
151
+ return;
152
+ }
153
+ // Laravel numbers the rows it was sent, which are only the ones filled in. Here a
154
+ // row is called by its number on screen, so every number it mentions is translated.
155
+ const onScreen = (sent: number) => {
156
+ const row = filled[sent - 1];
157
+ return row ? rows.findIndex((candidate) => candidate.key === row.key) + 1 : sent;
158
+ };
159
+ const fromServer: Record<number, Record<string, string>> = {};
160
+ const numbers: number[] = [];
161
+ for (const problem of result.rows ?? []) {
162
+ const row = filled[problem.row - 1];
163
+ if (!row) continue;
164
+ numbers.push(onScreen(problem.row));
165
+ fromServer[row.key] = {};
166
+ for (const issue of problem.issues) {
167
+ const field = issue.path.split(".")[0] ?? "";
168
+ const known = fields.some(([key]) => key === field);
169
+ const label = all.find(([key]) => key === field)?.[1].label;
170
+ const message = issue.message.replace(/\brow (\d+)\b/g, (_, sent: string) => `row ${onScreen(Number(sent))}`);
171
+ // A problem with a field that has no column here is said beside the row instead.
172
+ fromServer[row.key]![known ? field : ""] ??= known || !label ? message : `${label}: ${message}`;
173
+ }
174
+ }
175
+ setProblems(fromServer);
176
+ setError(numbers.length === 0 ? result.error : numbers.length === 1 ? `Nothing was created: row ${numbers[0]} needs fixing.` : `Nothing was created: ${numbers.length} rows need fixing.`);
177
+ });
178
+ }
179
+
180
+ return (
181
+ <>
182
+ {missing.length > 0 && (
183
+ <Alert>
184
+ <AlertDescription>
185
+ {missing.map(([, def]) => def.label).join(", ")} {missing.length === 1 ? "is" : "are"} required and can&apos;t be filled in here, so these rows would be
186
+ refused. Use New {resource.label.toLowerCase()} for {resource.pluralLabel.toLowerCase()} instead.
187
+ </AlertDescription>
188
+ </Alert>
189
+ )}
190
+
191
+ <div className="min-h-0 flex-1 overflow-auto rounded-md border">
192
+ <table className="w-full border-collapse text-sm">
193
+ <thead className="sticky top-0 z-10 bg-muted text-left text-muted-foreground">
194
+ <tr>
195
+ <th scope="col" className="w-10 px-2 py-2 text-center font-medium">
196
+ #
197
+ </th>
198
+ {fields.map(([key, def]) => (
199
+ <th key={key} scope="col" className={cn("px-2 py-2 font-medium whitespace-nowrap", def.kind === "boolean" ? "w-24 text-center" : "min-w-44")}>
200
+ {def.label}
201
+ {def.required !== false && def.kind !== "boolean" && (
202
+ <span className="text-destructive" aria-label="required">
203
+ {" "}
204
+ *
205
+ </span>
206
+ )}
207
+ </th>
208
+ ))}
209
+ <th scope="col" className="w-10">
210
+ <span className="sr-only">Remove</span>
211
+ </th>
212
+ </tr>
213
+ </thead>
214
+ <tbody>
215
+ {rows.map((row, index) => {
216
+ const wrong = problems[row.key] ?? {};
217
+ const messages = Object.entries(wrong).map(([field, message]) => {
218
+ const label = fields.find(([key]) => key === field)?.[1].label;
219
+ return label ? `${label}: ${message}` : message;
220
+ });
221
+ return (
222
+ <tr key={row.key} className="border-t align-top">
223
+ <td className="px-2 py-3 text-center text-muted-foreground tabular-nums">{index + 1}</td>
224
+ {fields.map(([key, def]) => (
225
+ <td key={key} className={cn("px-1.5 py-1.5", def.kind === "boolean" && "text-center")}>
226
+ <Cell
227
+ id={`bulk-${row.key}-${key}`}
228
+ resourceName={resource.name}
229
+ fieldKey={key}
230
+ field={def}
231
+ label={`${def.label}, row ${index + 1}`}
232
+ value={row.values[key] ?? ""}
233
+ invalid={Boolean(wrong[key])}
234
+ disabled={pending}
235
+ relation={relations[key]}
236
+ onChange={(value) => change(row.key, key, value)}
237
+ />
238
+ {/* Said under the cell it is about. One with no cell of its own goes under the first. */}
239
+ {key === fields[0]?.[0] && messages.length > 0 && (
240
+ <p role="alert" className="mt-1 w-max max-w-[60vw] text-left text-xs text-destructive">
241
+ {messages.join(" · ")}
242
+ </p>
243
+ )}
244
+ </td>
245
+ ))}
246
+ <td className="px-1 py-1.5">
247
+ <Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove row ${index + 1}`} disabled={pending} onClick={() => removeRow(row.key)}>
248
+ <XIcon />
249
+ </Button>
250
+ </td>
251
+ </tr>
252
+ );
253
+ })}
254
+ </tbody>
255
+ </table>
256
+ </div>
257
+
258
+ <div className="flex flex-wrap items-center gap-2">
259
+ <Button type="button" variant="outline" size="sm" disabled={pending || rows.length >= MOST_ROWS} onClick={() => addRows(1)}>
260
+ <PlusIcon data-icon="inline-start" />
261
+ Add row
262
+ </Button>
263
+ <Button type="button" variant="ghost" size="sm" disabled={pending || rows.length >= MOST_ROWS} onClick={() => addRows(5)}>
264
+ Add 5 rows
265
+ </Button>
266
+ {error && (
267
+ <p role="alert" className="text-sm text-destructive">
268
+ {error}
269
+ </p>
270
+ )}
271
+ </div>
272
+
273
+ <DialogFooter>
274
+ <Button type="button" variant="ghost" disabled={pending} onClick={onDone}>
275
+ Cancel
276
+ </Button>
277
+ <Button type="button" disabled={pending || filled.length === 0 || missing.length > 0} onClick={save}>
278
+ {pending && <Spinner />}
279
+ {filled.length === 0 ? `Create ${resource.pluralLabel.toLowerCase()}` : `Create ${filled.length} ${noun}`}
280
+ </Button>
281
+ </DialogFooter>
282
+ </>
283
+ );
284
+ }
285
+
286
+ /**
287
+ * One cell. Long text is a single line here and a tick box is a tick box: a grid is for
288
+ * typing across, and a four-line text area or a labelled switch in every row is neither
289
+ * quick nor readable. Everything else is the form's own control, so a date, a price or a
290
+ * link to another record behaves exactly as it does there.
291
+ */
292
+ function Cell({
293
+ id,
294
+ resourceName,
295
+ fieldKey,
296
+ field,
297
+ label,
298
+ value,
299
+ invalid,
300
+ disabled,
301
+ relation,
302
+ onChange,
303
+ }: {
304
+ id: string;
305
+ resourceName: string;
306
+ fieldKey: string;
307
+ field: FieldDef;
308
+ label: string;
309
+ value: string | boolean;
310
+ invalid: boolean;
311
+ disabled: boolean;
312
+ relation?: RelationMeta & { initialTitle?: string };
313
+ onChange: (value: string | boolean) => void;
314
+ }) {
315
+ if (field.kind === "boolean") {
316
+ return <Checkbox id={id} aria-label={label} checked={value === true} disabled={disabled} onCheckedChange={(checked) => onChange(checked === true)} className="mt-2" />;
317
+ }
318
+ if (field.kind === "text") {
319
+ return <Input id={id} aria-label={label} value={typeof value === "string" ? value : ""} disabled={disabled} aria-invalid={invalid || undefined} onChange={(event) => onChange(event.target.value)} />;
320
+ }
321
+ return (
322
+ <div aria-label={label} role="group">
323
+ <FieldWidget id={id} name={id} resourceName={resourceName} fieldKey={fieldKey} field={field} value={value} onChange={onChange} invalid={invalid} disabled={disabled} relation={relation} />
324
+ </div>
325
+ );
326
+ }
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ import { useSearchParams } from "next/navigation";
4
+ import { useEffect, useId, useMemo, useState } from "react";
5
+ import { optionLabel, type ClientResource } from "@flaredev/core";
6
+ import { BarChart3Icon, ChevronDownIcon } from "lucide-react";
7
+ import { resourceInsightsAction, type InsightsData, type InsightsUnit } from "@/app/dashboard/actions";
8
+ import { Spinner } from "@/components/ui/spinner";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ const UNITS: { key: InsightsUnit; label: string; per: string; span: string }[] = [
12
+ { key: "day", label: "Daily", per: "day", span: "the last 30 days" },
13
+ { key: "week", label: "Weekly", per: "week", span: "the last 26 weeks" },
14
+ { key: "month", label: "Monthly", per: "month", span: "the last 12 months" },
15
+ ];
16
+
17
+ /** A period as someone would say it: "9 Oct", "the week of 5 Oct", "Oct 2026". */
18
+ function periodLabel(unit: InsightsUnit, bucket: string, long = false): string {
19
+ // UTC throughout: a bucket is a calendar day or month, not a moment, and must not slide a day in another timezone.
20
+ const date = new Date(`${unit === "month" ? `${bucket}-01` : bucket}T00:00:00Z`);
21
+ if (unit === "month") return new Intl.DateTimeFormat("en-GB", { month: "short", year: "numeric", timeZone: "UTC" }).format(date);
22
+ const day = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", ...(long ? { year: "numeric" } : {}), timeZone: "UTC" }).format(date);
23
+ return unit === "week" && long ? `the week of ${day}` : day;
24
+ }
25
+
26
+ /**
27
+ * Charts above a list, closed until somebody wants them.
28
+ *
29
+ * Closed is the default on purpose. The panel's numbers are a grouped query each over
30
+ * everything the list matches, which is work an ordinary visit to a list doesn't need.
31
+ * So nothing is asked for until it is opened, and whether it was left open is remembered
32
+ * for the next visit.
33
+ *
34
+ * What it charts is the rows the table is showing: the same search and filters go on the
35
+ * request, so narrowing the list to one category redraws the charts for that category.
36
+ *
37
+ * Drawn with plain elements and no chart library: bars are all these are, and a library
38
+ * for bars would be several hundred kilobytes to draw rectangles.
39
+ */
40
+ export function ResourceInsights({ resource, total }: { resource: ClientResource; /** The list's own count: when it changes, so have the charts. */ total: number }) {
41
+ const panel = useId();
42
+ const remembered = `nevela.insights.${resource.name}`;
43
+ const [open, setOpen] = useState(false);
44
+ const [unit, setUnit] = useState<InsightsUnit>("day");
45
+ const [data, setData] = useState<InsightsData | null>(null);
46
+ const [loading, setLoading] = useState(false);
47
+ const [error, setError] = useState<string | null>(null);
48
+
49
+ // Only what narrows the list. A page number or a sort order changes no count.
50
+ const params = useSearchParams();
51
+ const narrowed = useMemo(() => {
52
+ const kept = new URLSearchParams();
53
+ for (const [key, value] of params) if (key === "q" || key.startsWith("filter[")) kept.append(key, value);
54
+ kept.sort();
55
+ return kept.toString();
56
+ }, [params]);
57
+
58
+ // Read after the first paint: the server can't know what this browser remembers.
59
+ useEffect(() => {
60
+ try {
61
+ if (window.localStorage.getItem(remembered) === "open") setOpen(true);
62
+ } catch {
63
+ // No storage (a private window, or it is switched off): it just starts closed.
64
+ }
65
+ }, [remembered]);
66
+
67
+ function toggle() {
68
+ const next = !open;
69
+ setOpen(next);
70
+ try {
71
+ window.localStorage.setItem(remembered, next ? "open" : "closed");
72
+ } catch {
73
+ // Not remembered, and that is all.
74
+ }
75
+ }
76
+
77
+ useEffect(() => {
78
+ if (!open) return;
79
+ let current = true;
80
+ setLoading(true);
81
+ void resourceInsightsAction(resource.name, narrowed, unit).then((result) => {
82
+ if (!current) return;
83
+ setLoading(false);
84
+ if (result.ok) {
85
+ setData(result.data);
86
+ setError(null);
87
+ } else {
88
+ setError(result.error);
89
+ }
90
+ });
91
+ return () => {
92
+ current = false;
93
+ };
94
+ }, [open, unit, narrowed, total, resource.name]);
95
+
96
+ const chosen = UNITS.find((entry) => entry.key === unit)!;
97
+ const plural = resource.pluralLabel.toLowerCase();
98
+
99
+ return (
100
+ <section className="rounded-xl border bg-card">
101
+ <button type="button" onClick={toggle} aria-expanded={open} aria-controls={panel} className="flex w-full items-center gap-2 rounded-xl px-4 py-3 text-left text-sm font-medium hover:bg-muted/50">
102
+ <BarChart3Icon className="size-4 text-muted-foreground" />
103
+ Insights
104
+ <span className="truncate font-normal text-muted-foreground">{narrowed ? `for the ${plural} this list is showing` : `when ${plural} were created, and how they split`}</span>
105
+ {loading && <Spinner className="ml-1" />}
106
+ <ChevronDownIcon className={cn("ml-auto size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
107
+ </button>
108
+
109
+ {open && (
110
+ <div id={panel} className="border-t p-4">
111
+ {error && !data && (
112
+ <p role="alert" className="text-sm text-destructive">
113
+ {error}
114
+ </p>
115
+ )}
116
+ {!data && !error && <p className="text-sm text-muted-foreground">Counting…</p>}
117
+ {data && (
118
+ <div className={cn("grid gap-x-10 gap-y-8", data.breakdown.length > 0 && "lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]", loading && "opacity-60")}>
119
+ <div className="flex min-w-0 flex-col gap-3">
120
+ <div className="flex flex-wrap items-center justify-between gap-2">
121
+ <p className="text-sm">
122
+ <span className="font-medium">Created per {chosen.per}</span> <span className="text-muted-foreground">· {chosen.span}</span>
123
+ </p>
124
+ <div role="group" aria-label="Period" className="flex rounded-md border p-0.5 text-xs">
125
+ {UNITS.map((entry) => (
126
+ <button
127
+ key={entry.key}
128
+ type="button"
129
+ aria-pressed={entry.key === unit}
130
+ onClick={() => setUnit(entry.key)}
131
+ className={cn("rounded px-2 py-1", entry.key === unit ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}
132
+ >
133
+ {entry.label}
134
+ </button>
135
+ ))}
136
+ </div>
137
+ </div>
138
+ <Series unit={data.unit} series={data.series} noun={plural} />
139
+ </div>
140
+
141
+ {data.breakdown.length > 0 && (
142
+ <div className="flex min-w-0 flex-col gap-6">
143
+ {data.breakdown.map((group) => (
144
+ <Breakdown key={group.field} resource={resource} group={group} />
145
+ ))}
146
+ </div>
147
+ )}
148
+ </div>
149
+ )}
150
+ </div>
151
+ )}
152
+ </section>
153
+ );
154
+ }
155
+
156
+ function Series({ unit, series, noun }: { unit: InsightsUnit; series: InsightsData["series"]; noun: string }) {
157
+ const most = Math.max(0, ...series.map((point) => point.count));
158
+ const sum = series.reduce((all, point) => all + point.count, 0);
159
+ const peak = series.find((point) => point.count === most);
160
+ const middle = series[Math.floor(series.length / 2)];
161
+ const summary =
162
+ sum === 0 || !peak
163
+ ? `No ${noun} were created in this time.`
164
+ : `${sum.toLocaleString()} created in this time. The most was ${most.toLocaleString()}, ${unit === "day" ? "on" : "in"} ${periodLabel(unit, peak.bucket, true)}.`;
165
+
166
+ return (
167
+ <figure className="flex flex-col gap-2">
168
+ <div role="img" aria-label={summary} className="flex h-40 items-end gap-0.5 border-b pb-px">
169
+ {series.map((point) => (
170
+ <div key={point.bucket} title={`${point.count.toLocaleString()} · ${periodLabel(unit, point.bucket, true)}`} className="group flex h-full min-w-0 flex-1 items-end">
171
+ <div
172
+ // A count of one in a tall chart still has to be seen: never thinner than a hairline.
173
+ style={{ height: most > 0 && point.count > 0 ? `max(${(point.count / most) * 100}%, 3px)` : "1px" }}
174
+ className={cn("w-full rounded-t-sm", point.count > 0 ? "bg-primary/75 group-hover:bg-primary" : "bg-border")}
175
+ />
176
+ </div>
177
+ ))}
178
+ </div>
179
+ <div className="flex justify-between text-xs text-muted-foreground tabular-nums" aria-hidden="true">
180
+ <span>{series[0] && periodLabel(unit, series[0].bucket)}</span>
181
+ <span>{middle && periodLabel(unit, middle.bucket)}</span>
182
+ <span>{series.at(-1) && periodLabel(unit, series.at(-1)!.bucket)}</span>
183
+ </div>
184
+ <figcaption className="text-xs text-muted-foreground">{summary}</figcaption>
185
+ </figure>
186
+ );
187
+ }
188
+
189
+ function Breakdown({ resource, group }: { resource: ClientResource; group: InsightsData["breakdown"][number] }) {
190
+ const def = resource.fields[group.field];
191
+ const sum = group.slices.reduce((all, slice) => all + slice.count, 0);
192
+ const name = (value: string) => {
193
+ if (value === "") return "Not set";
194
+ if (group.kind === "boolean") return value === "true" ? "Yes" : "No";
195
+ return def && def.kind === "enum" ? optionLabel(def, value) : value;
196
+ };
197
+
198
+ return (
199
+ <div className="flex flex-col gap-2">
200
+ <p className="text-sm font-medium">{def && "label" in def && def.label ? String(def.label) : group.field}</p>
201
+ <ul className="flex flex-col gap-1.5">
202
+ {group.slices.map((slice) => {
203
+ const share = sum > 0 ? (slice.count / sum) * 100 : 0;
204
+ return (
205
+ <li key={slice.value} className="grid grid-cols-[minmax(4rem,7rem)_1fr_auto] items-center gap-3 text-sm">
206
+ <span className="truncate text-muted-foreground">{name(slice.value)}</span>
207
+ <span className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden="true">
208
+ <span className="block h-full rounded-full bg-primary/75" style={{ width: `${share}%` }} />
209
+ </span>
210
+ <span className="tabular-nums">
211
+ {slice.count.toLocaleString()} <span className="text-muted-foreground">· {Math.round(share)}%</span>
212
+ </span>
213
+ </li>
214
+ );
215
+ })}
216
+ </ul>
217
+ </div>
218
+ );
219
+ }
@@ -24,6 +24,8 @@ import { ExportButton } from "./export-button";
24
24
  import { isInlineEditable } from "./editable";
25
25
  import { InlineCell } from "./inline-cell";
26
26
  import type { RelationMeta } from "./fields/field-widget";
27
+ import { BulkCreateButton } from "./bulk-create-grid";
28
+ import { ResourceInsights } from "./resource-insights";
27
29
  import { ImportDialog } from "./import-dialog";
28
30
  import { LocalTime } from "./local-time";
29
31
  import { hrefWith, toSearchParams, type SearchParams } from "./query";
@@ -104,12 +106,14 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
104
106
  return (
105
107
  <TableSelection ids={rowIds}>
106
108
  <div className="flex flex-col gap-4">
109
+ <ResourceInsights resource={forClient} total={meta.total} />
107
110
  <div className="flex flex-wrap items-center justify-between gap-2">
108
111
  <ResourceTableToolbar resource={forClient} />
109
112
  <div className="flex flex-wrap items-center gap-2">
110
113
  <ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
111
114
  <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
112
115
  {permissions.create && <ImportDialog resource={forClient} />}
116
+ {permissions.create && <BulkCreateButton resource={forClient} relations={relations} />}
113
117
  {permissions.create &&
114
118
  (overlayForms ? (
115
119
  <NewRecordButton resource={forClient} relations={relations} listHref={basePath} />
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {