akanjs 3.0.0-alpha.61 → 3.0.0-alpha.63
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/constant/fieldQueryMeta.ts +35 -0
- package/constant/index.ts +1 -0
- package/fetch/client/httpClient.ts +5 -2
- package/fetch/fetchType/appliedReturn.type.ts +5 -1
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -5
- package/types/constant/fieldQueryMeta.d.ts +17 -0
- package/types/constant/index.d.ts +1 -0
- package/types/fetch/fetchType/appliedReturn.type.d.ts +5 -1
- package/types/ui/Data/Dashboard.d.ts +12 -2
- package/types/ui/Data/ListContainer.d.ts +5 -1
- package/types/ui/Data/QueryMaker.d.ts +11 -2
- package/types/ui/DatePicker.d.ts +15 -11
- package/types/ui/Model/AdminPanel.d.ts +7 -2
- package/ui/Data/Dashboard.tsx +52 -19
- package/ui/Data/ListContainer.tsx +26 -11
- package/ui/Data/QueryMaker.tsx +24 -9
- package/ui/Data/RefPicker.tsx +3 -4
- package/ui/DatePicker.tsx +110 -128
- package/ui/Model/AdminPanel.tsx +11 -2
- package/ui/styles.css +4 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { FIELD_META } from "akanjs/base";
|
|
2
|
+
import { ConstantRegistry } from "./constantRegistry";
|
|
3
|
+
import type { FieldObject } from "./fieldInfo";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The query a field's value stands for, declared on the field itself with `.meta(...)`. A summary counter is the
|
|
7
|
+
* case this exists for: `hau` is however many rows one filter returns, so the tile showing it can open that exact
|
|
8
|
+
* listing without the page restating the filter beside it.
|
|
9
|
+
*
|
|
10
|
+
* The shape is read structurally rather than by class, so an app declares it with whatever builder it already has.
|
|
11
|
+
*/
|
|
12
|
+
export interface FieldQueryMeta {
|
|
13
|
+
/** The model the query runs against. */
|
|
14
|
+
refName: string;
|
|
15
|
+
/** One of that model's declared filter keys. */
|
|
16
|
+
queryKey: string | null;
|
|
17
|
+
/** Read when the query is applied, so an arg relative to now — `() => [dayjs().subtract(1, "hour")]` — is current. */
|
|
18
|
+
queryArgs?: unknown[] | (() => unknown[]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isFieldQueryMeta = (meta: unknown): meta is FieldQueryMeta => {
|
|
22
|
+
if (!meta || typeof meta !== "object") return false;
|
|
23
|
+
const { refName, queryKey, queryArgs } = meta as Record<string, unknown>;
|
|
24
|
+
if (typeof refName !== "string" || !refName) return false;
|
|
25
|
+
if (queryKey !== null && typeof queryKey !== "string") return false;
|
|
26
|
+
return queryArgs === undefined || Array.isArray(queryArgs) || typeof queryArgs === "function";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The query one field of `refName` names, or nothing when the model, the field, or the declaration is absent. */
|
|
30
|
+
export const fieldQueryMetaOf = (refName: string, field: string): FieldQueryMeta | undefined => {
|
|
31
|
+
const cnst = ConstantRegistry.getDatabase(refName, { allowEmpty: true });
|
|
32
|
+
const fieldMap = cnst?.full[FIELD_META] as FieldObject | undefined;
|
|
33
|
+
const meta = fieldMap?.[field]?.meta;
|
|
34
|
+
return isFieldQueryMeta(meta) ? meta : undefined;
|
|
35
|
+
};
|
package/constant/index.ts
CHANGED
|
@@ -115,8 +115,11 @@ export class HttpClient {
|
|
|
115
115
|
const argValue = argMap.get(arg.name);
|
|
116
116
|
if (argValue === null || argValue === undefined) return;
|
|
117
117
|
|
|
118
|
-
if (arg.refName === "Any")
|
|
119
|
-
|
|
118
|
+
if (arg.refName === "Any") {
|
|
119
|
+
|
|
120
|
+
const encoded = JSON.stringify(argValue);
|
|
121
|
+
if (encoded !== undefined) searchParams.set(arg.name, encoded);
|
|
122
|
+
} else if (arg.arrDepth && Array.isArray(argValue))
|
|
120
123
|
argValue.forEach((value) => {
|
|
121
124
|
searchParams.append(arg.name, String(value));
|
|
122
125
|
});
|
|
@@ -11,7 +11,11 @@ export type SliceMeta = {
|
|
|
11
11
|
/** What the root slice takes: one of the model's declared filter queries, and the args that filter asks for. */
|
|
12
12
|
export interface QuerySetting {
|
|
13
13
|
queryKey: string;
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Read when the query is applied rather than when the setting is written, so a thunk keeps an arg relative to
|
|
16
|
+
* now — `() => [dayjs().subtract(1, "hour")]` — current at the moment the user asks for it.
|
|
17
|
+
*/
|
|
18
|
+
args?: unknown[] | (() => unknown[]);
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export type ServerInit<
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akanjs",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.63",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -216,7 +216,6 @@
|
|
|
216
216
|
"postgres": "^3.4.9",
|
|
217
217
|
"protobufjs": "^8.4.0",
|
|
218
218
|
"react": "19.2.7",
|
|
219
|
-
"react-datepicker": "^9.1.0",
|
|
220
219
|
"react-dom": "19.2.7",
|
|
221
220
|
"react-icons": "^5.6.0",
|
|
222
221
|
"react-refresh": "^0.18.0",
|
|
@@ -324,9 +323,6 @@
|
|
|
324
323
|
"react": {
|
|
325
324
|
"optional": true
|
|
326
325
|
},
|
|
327
|
-
"react-datepicker": {
|
|
328
|
-
"optional": true
|
|
329
|
-
},
|
|
330
326
|
"react-dom": {
|
|
331
327
|
"optional": true
|
|
332
328
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query a field's value stands for, declared on the field itself with `.meta(...)`. A summary counter is the
|
|
3
|
+
* case this exists for: `hau` is however many rows one filter returns, so the tile showing it can open that exact
|
|
4
|
+
* listing without the page restating the filter beside it.
|
|
5
|
+
*
|
|
6
|
+
* The shape is read structurally rather than by class, so an app declares it with whatever builder it already has.
|
|
7
|
+
*/
|
|
8
|
+
export interface FieldQueryMeta {
|
|
9
|
+
/** The model the query runs against. */
|
|
10
|
+
refName: string;
|
|
11
|
+
/** One of that model's declared filter keys. */
|
|
12
|
+
queryKey: string | null;
|
|
13
|
+
/** Read when the query is applied, so an arg relative to now — `() => [dayjs().subtract(1, "hour")]` — is current. */
|
|
14
|
+
queryArgs?: unknown[] | (() => unknown[]);
|
|
15
|
+
}
|
|
16
|
+
/** The query one field of `refName` names, or nothing when the model, the field, or the declaration is absent. */
|
|
17
|
+
export declare const fieldQueryMetaOf: (refName: string, field: string) => FieldQueryMeta | undefined;
|
|
@@ -3,6 +3,7 @@ export * from "./constantRegistry.d.ts";
|
|
|
3
3
|
export * from "./crystalize.d.ts";
|
|
4
4
|
export * from "./deserialize.d.ts";
|
|
5
5
|
export * from "./fieldInfo.d.ts";
|
|
6
|
+
export * from "./fieldQueryMeta.d.ts";
|
|
6
7
|
export * from "./getDefault.d.ts";
|
|
7
8
|
export * from "./immerify.d.ts";
|
|
8
9
|
export * from "./labelOf.d.ts";
|
|
@@ -9,7 +9,11 @@ export type SliceMeta = {
|
|
|
9
9
|
/** What the root slice takes: one of the model's declared filter queries, and the args that filter asks for. */
|
|
10
10
|
export interface QuerySetting {
|
|
11
11
|
queryKey: string;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Read when the query is applied rather than when the setting is written, so a thunk keeps an arg relative to
|
|
14
|
+
* now — `() => [dayjs().subtract(1, "hour")]` — current at the moment the user asks for it.
|
|
15
|
+
*/
|
|
16
|
+
args?: unknown[] | (() => unknown[]);
|
|
13
17
|
}
|
|
14
18
|
export type ServerInit<RefName extends string, Light, Insight = any, QueryArgs = any, Filter extends FilterInstance = any, _CapitalizedRefName extends string = Capitalize<RefName>, _LightObj = GetStateObject<Light>, _InsightObj = GetStateObject<Insight>, _Sort = ExtractSort<Filter>> = SliceMeta & {
|
|
15
19
|
[K in `${RefName}ObjList`]: _LightObj[];
|
|
@@ -2,13 +2,23 @@ import type { QuerySetting, SliceMeta } from "akanjs/fetch";
|
|
|
2
2
|
export interface DashboardProps<T extends string, State> {
|
|
3
3
|
className?: string;
|
|
4
4
|
summary: Record<string, unknown>;
|
|
5
|
+
/** The listing these tiles belong to. Kept so a caller names its target; the labels are app-level keys. */
|
|
5
6
|
slice: SliceMeta;
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Overrides the filter a column applies. A column left out still narrows the listing when its own field
|
|
9
|
+
* declares a query with `.meta(...)`; one with neither renders as a plain tile.
|
|
10
|
+
*/
|
|
7
11
|
queryMap?: {
|
|
8
12
|
[column: string]: QuerySetting;
|
|
9
13
|
};
|
|
14
|
+
/** Model whose fields the columns name. Its `.meta(...)` declarations are where a tile's filter comes from. */
|
|
15
|
+
summaryRefName?: string;
|
|
16
|
+
/** Applies one column's filter. Without it a mapped column still renders, but as a plain tile. */
|
|
17
|
+
onSelect?: (setting: QuerySetting, column: string) => void;
|
|
18
|
+
/** The filter key the listing is showing, so a tile stops looking active once the toolbar moves off it. */
|
|
19
|
+
queryKey?: string;
|
|
10
20
|
columns?: string[];
|
|
11
21
|
presents?: string[];
|
|
12
22
|
hidePresents?: boolean;
|
|
13
23
|
}
|
|
14
|
-
export default function Dashboard<T extends string, State>({ className, summary, slice, queryMap, columns, presents, hidePresents, }: DashboardProps<T, State>): import("react/jsx-runtime").JSX.Element | null;
|
|
24
|
+
export default function Dashboard<T extends string, State>({ className, summary, slice, queryMap, summaryRefName, onSelect, queryKey, columns, presents, hidePresents, }: DashboardProps<T, State>): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -34,9 +34,13 @@ export interface ListContainerProps<T extends string, State, Input, Full extends
|
|
|
34
34
|
tools?: DataTool[] | ((modelList: Light[]) => DataTool[]);
|
|
35
35
|
/** Per-row actions or action factory. */
|
|
36
36
|
actions?: DataAction[] | ((item: Light, idx: number) => DataAction[]);
|
|
37
|
-
renderDashboard?: ({ summary, hidePresents, }: {
|
|
37
|
+
renderDashboard?: ({ summary, hidePresents, onSelect, queryKey, }: {
|
|
38
38
|
summary: Record<string, unknown>;
|
|
39
39
|
hidePresents?: boolean;
|
|
40
|
+
/** Applies one summary column's filter to this listing, in place. */
|
|
41
|
+
onSelect: (setting: QuerySetting) => void;
|
|
42
|
+
/** The filter key the listing is showing right now. */
|
|
43
|
+
queryKey: string;
|
|
40
44
|
}) => ReactNode;
|
|
41
45
|
renderItem?: (props: ModelProps<any, any>) => ReactNode;
|
|
42
46
|
renderTemplate?: (props: any) => ReactNode | null;
|
|
@@ -5,14 +5,21 @@ interface QueryMakerProps {
|
|
|
5
5
|
slice: SliceMeta;
|
|
6
6
|
query?: QuerySetting;
|
|
7
7
|
/** Where a completed filter goes. Defaults to the slice's own store, which is where a listing reads it. */
|
|
8
|
-
onApply?: (setting:
|
|
8
|
+
onApply?: (setting: ResolvedQuerySetting) => void;
|
|
9
9
|
}
|
|
10
10
|
export interface QueryMakerState {
|
|
11
11
|
queryKeys: string[];
|
|
12
12
|
args: SerializedArg[];
|
|
13
|
-
setting:
|
|
13
|
+
setting: ResolvedQuerySetting;
|
|
14
14
|
selectKey: (queryKey: string) => void;
|
|
15
15
|
setArg: (idx: number, value: unknown) => void;
|
|
16
|
+
/** Applies a whole filter at once, for a control that stands for one — a dashboard tile, a saved view. */
|
|
17
|
+
applySetting: (setting: QuerySetting) => void;
|
|
18
|
+
}
|
|
19
|
+
/** A setting whose thunk has been read, which is the only form the store and the wire accept. */
|
|
20
|
+
export interface ResolvedQuerySetting {
|
|
21
|
+
queryKey: string;
|
|
22
|
+
args: unknown[];
|
|
16
23
|
}
|
|
17
24
|
interface QueryMakerKeyProps {
|
|
18
25
|
className?: string;
|
|
@@ -25,6 +32,8 @@ interface QueryMakerArgsProps {
|
|
|
25
32
|
slice: SliceMeta;
|
|
26
33
|
state: QueryMakerState;
|
|
27
34
|
}
|
|
35
|
+
/** A filter's args may be written as a thunk, so that an arg relative to now is read when the filter is applied. */
|
|
36
|
+
export declare const resolveQuerySetting: (setting: QuerySetting) => ResolvedQuerySetting;
|
|
28
37
|
/** A filter the server will accept: every arg it declared as required has been given a value. */
|
|
29
38
|
export declare const isReadyQuery: (args: SerializedArg[], values: unknown[]) => boolean;
|
|
30
39
|
/**
|
package/types/ui/DatePicker.d.ts
CHANGED
|
@@ -1,37 +1,41 @@
|
|
|
1
1
|
import { type Dayjs } from "akanjs/base";
|
|
2
2
|
export interface DatePickerProps {
|
|
3
|
+
className?: string;
|
|
3
4
|
value?: Dayjs | null;
|
|
4
5
|
onChange: (value: Dayjs | null) => void;
|
|
5
6
|
showTime?: boolean;
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
/** Earliest selectable value. The browser enforces it. */
|
|
8
|
+
min?: Dayjs | null;
|
|
9
|
+
/** Latest selectable value. The browser enforces it. */
|
|
10
|
+
max?: Dayjs | null;
|
|
11
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
8
12
|
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
9
|
-
className?: string;
|
|
10
|
-
placement?: "top" | "bottom" | "left" | "right";
|
|
11
13
|
defaultValue?: Dayjs;
|
|
12
14
|
}
|
|
13
15
|
export interface RangePickerProps {
|
|
16
|
+
className?: string;
|
|
14
17
|
value: [Dayjs | null, Dayjs | null];
|
|
15
18
|
onChange: (value: [Dayjs | null, Dayjs | null]) => void;
|
|
16
|
-
format?: string;
|
|
17
19
|
showTime?: boolean;
|
|
18
|
-
|
|
20
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
19
21
|
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
20
|
-
className?: string;
|
|
21
22
|
}
|
|
22
23
|
export interface TimePickerProps {
|
|
24
|
+
className?: string;
|
|
23
25
|
value: Dayjs | null;
|
|
24
26
|
onChange: (value: Dayjs) => void;
|
|
25
|
-
format?: string;
|
|
26
|
-
timeIntervals?: number;
|
|
27
|
-
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
28
|
-
className?: string;
|
|
29
27
|
disabled?: boolean;
|
|
28
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
29
|
+
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
32
|
* Date picker. `DatePicker`, `DatePicker.RangePicker`, and `DatePicker.TimePicker` each resolve to a
|
|
33
33
|
* route-scoped override when a `page/**\/_overrides.tsx` in the route's ancestry declares one (slots
|
|
34
34
|
* `DatePicker`, `DatePickerRangePicker`, `DatePickerTimePicker`).
|
|
35
|
+
*
|
|
36
|
+
* Each renders one native `<input type="date" | "datetime-local" | "time">`, so the calendar is the browser's
|
|
37
|
+
* own: an OS wheel on mobile, keyboard entry everywhere, nothing shipped in the bundle. What that costs is a
|
|
38
|
+
* themed popup, a chosen display format, and per-day disabling — an app needing any of those overrides the slot.
|
|
35
39
|
*/
|
|
36
40
|
export declare const DatePicker: import("react").ComponentType<DatePickerProps> & {
|
|
37
41
|
RangePicker: import("react").ComponentType<RangePickerProps>;
|
|
@@ -21,10 +21,15 @@ interface AdminPanelProps<T extends string, State, Input, Full extends {
|
|
|
21
21
|
template?: any;
|
|
22
22
|
unit?: any;
|
|
23
23
|
view?: any;
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Overrides the filter a summary column applies. A column left out still narrows the listing when its own
|
|
26
|
+
* field declares one with `.meta(...)`.
|
|
27
|
+
*/
|
|
25
28
|
queryMap?: {
|
|
26
29
|
[column: string]: QuerySetting;
|
|
27
30
|
};
|
|
31
|
+
/** Model whose fields `summaryColumns` name. Defaults to the `summary` model the state key already implies. */
|
|
32
|
+
summaryRefName?: string;
|
|
28
33
|
/** Keys of the app's `summary` state shown as dashboard tiles above the list. */
|
|
29
34
|
summaryColumns?: string[];
|
|
30
35
|
/** Model insight keys shown above the list. The header already carries the total count. */
|
|
@@ -34,5 +39,5 @@ export default function AdminPanel<RefName extends string, State, Input, Full ex
|
|
|
34
39
|
id: string;
|
|
35
40
|
}, Light extends {
|
|
36
41
|
id: string;
|
|
37
|
-
}>({ slice, components, queryMap, template, unit, view, summaryColumns, insightColumns, renderInsight, renderDashboard, ...props }: AdminPanelProps<RefName, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
|
|
42
|
+
}>({ slice, components, queryMap, summaryRefName, template, unit, view, summaryColumns, insightColumns, renderInsight, renderDashboard, ...props }: AdminPanelProps<RefName, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
|
|
38
43
|
export {};
|
package/ui/Data/Dashboard.tsx
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { cn, usePage } from "akanjs/client";
|
|
3
|
+
import { fieldQueryMetaOf } from "akanjs/constant";
|
|
3
4
|
import type { QuerySetting, SliceMeta } from "akanjs/fetch";
|
|
4
5
|
import { st } from "akanjs/store";
|
|
6
|
+
import { useState } from "react";
|
|
5
7
|
|
|
6
|
-
import { Link } from "../Link";
|
|
7
8
|
import { dictLabel, formatStat } from "./dataText";
|
|
8
9
|
|
|
9
10
|
export interface DashboardProps<T extends string, State> {
|
|
10
11
|
className?: string;
|
|
11
12
|
summary: Record<string, unknown>;
|
|
13
|
+
/** The listing these tiles belong to. Kept so a caller names its target; the labels are app-level keys. */
|
|
12
14
|
slice: SliceMeta;
|
|
13
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Overrides the filter a column applies. A column left out still narrows the listing when its own field
|
|
17
|
+
* declares a query with `.meta(...)`; one with neither renders as a plain tile.
|
|
18
|
+
*/
|
|
14
19
|
queryMap?: { [column: string]: QuerySetting };
|
|
20
|
+
/** Model whose fields the columns name. Its `.meta(...)` declarations are where a tile's filter comes from. */
|
|
21
|
+
summaryRefName?: string;
|
|
22
|
+
/** Applies one column's filter. Without it a mapped column still renders, but as a plain tile. */
|
|
23
|
+
onSelect?: (setting: QuerySetting, column: string) => void;
|
|
24
|
+
/** The filter key the listing is showing, so a tile stops looking active once the toolbar moves off it. */
|
|
25
|
+
queryKey?: string;
|
|
15
26
|
columns?: string[];
|
|
16
27
|
presents?: string[];
|
|
17
28
|
hidePresents?: boolean;
|
|
@@ -24,40 +35,62 @@ export default function Dashboard<T extends string, State>({
|
|
|
24
35
|
summary,
|
|
25
36
|
slice,
|
|
26
37
|
queryMap,
|
|
38
|
+
summaryRefName = "summary",
|
|
39
|
+
onSelect,
|
|
40
|
+
queryKey,
|
|
27
41
|
columns,
|
|
28
42
|
presents,
|
|
29
43
|
hidePresents,
|
|
30
44
|
}: DashboardProps<T, State>) {
|
|
31
|
-
const { refName } = slice;
|
|
32
45
|
const { l } = usePage();
|
|
33
46
|
const searchParams = st.use.searchParams({ agent: false });
|
|
34
47
|
const filter = Array.isArray(searchParams.filter) ? searchParams.filter[0] : searchParams.filter;
|
|
48
|
+
|
|
49
|
+
const [selected, setSelected] = useState(typeof filter === "string" ? filter : undefined);
|
|
50
|
+
|
|
51
|
+
const settingOf = (column: string): QuerySetting | undefined => {
|
|
52
|
+
if (queryMap?.[column]) return queryMap[column];
|
|
53
|
+
const meta = fieldQueryMetaOf(summaryRefName, column);
|
|
54
|
+
if (!meta?.queryKey || meta.refName !== slice.refName) return undefined;
|
|
55
|
+
return { queryKey: meta.queryKey, args: meta.queryArgs };
|
|
56
|
+
};
|
|
35
57
|
const shownColumns = (columns ?? []).filter((column) => summary[column] !== undefined);
|
|
36
58
|
const shownPresents = hidePresents ? [] : (presents ?? []).filter((column) => summary[column] !== undefined);
|
|
37
59
|
if (!shownColumns.length && !shownPresents.length) return null;
|
|
60
|
+
const activeColumn = selected && settingOf(selected)?.queryKey === queryKey ? selected : undefined;
|
|
38
61
|
return (
|
|
39
62
|
<div className={cn("mb-4 flex flex-wrap gap-2", className)}>
|
|
40
63
|
{[...shownColumns, ...shownPresents].map((column) => {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
64
|
+
const setting = settingOf(column);
|
|
65
|
+
const label = dictLabel(l._, `summary.${column}`, column);
|
|
66
|
+
const body = (
|
|
67
|
+
<>
|
|
68
|
+
<span className="truncate text-muted-foreground text-xs">{label}</span>
|
|
69
|
+
<span className="truncate font-semibold text-2xl text-primary tabular-nums">
|
|
70
|
+
{formatStat(summary[column])}
|
|
71
|
+
</span>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
return setting && onSelect ? (
|
|
75
|
+
<button
|
|
47
76
|
className={cn(
|
|
48
77
|
tileClassName,
|
|
49
|
-
"bg-card",
|
|
50
|
-
|
|
51
|
-
filter === column && linkable ? "border-primary" : "border-border",
|
|
78
|
+
"bg-card transition hover:border-primary/50",
|
|
79
|
+
activeColumn === column ? "border-primary" : "border-border",
|
|
52
80
|
)}
|
|
81
|
+
key={column}
|
|
82
|
+
onClick={() => {
|
|
83
|
+
setSelected(column);
|
|
84
|
+
onSelect(setting, column);
|
|
85
|
+
}}
|
|
86
|
+
type="button"
|
|
53
87
|
>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
</Link>
|
|
88
|
+
{body}
|
|
89
|
+
</button>
|
|
90
|
+
) : (
|
|
91
|
+
<div className={cn(tileClassName, "border-border bg-card")} key={column}>
|
|
92
|
+
{body}
|
|
93
|
+
</div>
|
|
61
94
|
);
|
|
62
95
|
})}
|
|
63
96
|
</div>
|
|
@@ -36,7 +36,7 @@ import { Select } from "../Select";
|
|
|
36
36
|
import DataCardList from "./CardList";
|
|
37
37
|
import { columnKey, downloadBlob, toCsvBlob, toJsonBlob } from "./dataExport";
|
|
38
38
|
import { dictLabel } from "./dataText";
|
|
39
|
-
import { QueryMakerArgs, QueryMakerKey, useQueryMaker } from "./QueryMaker";
|
|
39
|
+
import { QueryMakerArgs, QueryMakerKey, resolveQuerySetting, useQueryMaker } from "./QueryMaker";
|
|
40
40
|
import DataTableList from "./TableList";
|
|
41
41
|
|
|
42
42
|
const controlClassName = "h-9";
|
|
@@ -79,9 +79,15 @@ export interface ListContainerProps<
|
|
|
79
79
|
renderDashboard?: ({
|
|
80
80
|
summary,
|
|
81
81
|
hidePresents,
|
|
82
|
+
onSelect,
|
|
83
|
+
queryKey,
|
|
82
84
|
}: {
|
|
83
85
|
summary: Record<string, unknown>;
|
|
84
86
|
hidePresents?: boolean;
|
|
87
|
+
/** Applies one summary column's filter to this listing, in place. */
|
|
88
|
+
onSelect: (setting: QuerySetting) => void;
|
|
89
|
+
/** The filter key the listing is showing right now. */
|
|
90
|
+
queryKey: string;
|
|
85
91
|
}) => ReactNode;
|
|
86
92
|
renderItem?: (props: ModelProps<any, any>) => ReactNode;
|
|
87
93
|
renderTemplate?: (props: any) => ReactNode | null;
|
|
@@ -178,7 +184,10 @@ export default function ListContainer<
|
|
|
178
184
|
useEffect(() => {
|
|
179
185
|
|
|
180
186
|
const queryArgs = new Array(slice.argLength).fill(null) as unknown[];
|
|
181
|
-
if (initQuery)
|
|
187
|
+
if (initQuery) {
|
|
188
|
+
const { queryKey, args } = resolveQuerySetting(initQuery);
|
|
189
|
+
[queryArgs[0], queryArgs[1]] = [queryKey, args];
|
|
190
|
+
}
|
|
182
191
|
void storeDo[namesOfSlice.initModel](...queryArgs, { sort, ...init });
|
|
183
192
|
}, []);
|
|
184
193
|
|
|
@@ -252,16 +261,22 @@ export default function ListContainer<
|
|
|
252
261
|
|
|
253
262
|
const modelLabel = dictLabel(l._, `${sliceName}.modelName`, refName);
|
|
254
263
|
const RenderTitle = renderTitle ?? ((model: Full) => `${modelLabel} - ${model.id ? model.id : "New"}`);
|
|
255
|
-
const ModelDashboard = (): ReactNode => {
|
|
256
|
-
const Stat = renderDashboard;
|
|
257
264
|
|
|
258
|
-
|
|
259
|
-
|
|
265
|
+
const summary = storeSel<Record<string, unknown> | undefined>(
|
|
266
|
+
(state) => (state as { summary?: Record<string, unknown> }).summary,
|
|
267
|
+
);
|
|
268
|
+
const summaryLoading = storeSel<boolean>((state) => !!(state as { summaryLoading?: boolean }).summaryLoading);
|
|
269
|
+
const modelDashboard =
|
|
270
|
+
!renderDashboard || !summary ? null : summaryLoading ? (
|
|
271
|
+
<Loading.Skeleton className="mb-4" active />
|
|
272
|
+
) : (
|
|
273
|
+
renderDashboard({
|
|
274
|
+
summary,
|
|
275
|
+
hidePresents: true,
|
|
276
|
+
onSelect: queryState.applySetting,
|
|
277
|
+
queryKey: queryState.setting.queryKey,
|
|
278
|
+
})
|
|
260
279
|
);
|
|
261
|
-
const summaryLoading = storeSel<boolean>((state) => !!(state as { summaryLoading?: boolean }).summaryLoading);
|
|
262
|
-
if (!Stat || !summary) return null;
|
|
263
|
-
return summaryLoading ? <Loading.Skeleton className="mb-4" active /> : <Stat summary={summary} hidePresents />;
|
|
264
|
-
};
|
|
265
280
|
|
|
266
281
|
const queryMakerArgs = renderQueryMaker ? (
|
|
267
282
|
renderQueryMaker()
|
|
@@ -401,7 +416,7 @@ export default function ListContainer<
|
|
|
401
416
|
) : null}
|
|
402
417
|
</div>
|
|
403
418
|
</div>
|
|
404
|
-
{query ? null :
|
|
419
|
+
{query ? null : modelDashboard}
|
|
405
420
|
{queryMakerArgs}
|
|
406
421
|
<RenderInsight />
|
|
407
422
|
{view === "card" ? (
|
package/ui/Data/QueryMaker.tsx
CHANGED
|
@@ -16,14 +16,21 @@ interface QueryMakerProps {
|
|
|
16
16
|
slice: SliceMeta;
|
|
17
17
|
query?: QuerySetting;
|
|
18
18
|
/** Where a completed filter goes. Defaults to the slice's own store, which is where a listing reads it. */
|
|
19
|
-
onApply?: (setting:
|
|
19
|
+
onApply?: (setting: ResolvedQuerySetting) => void;
|
|
20
20
|
}
|
|
21
21
|
export interface QueryMakerState {
|
|
22
22
|
queryKeys: string[];
|
|
23
23
|
args: SerializedArg[];
|
|
24
|
-
setting:
|
|
24
|
+
setting: ResolvedQuerySetting;
|
|
25
25
|
selectKey: (queryKey: string) => void;
|
|
26
26
|
setArg: (idx: number, value: unknown) => void;
|
|
27
|
+
/** Applies a whole filter at once, for a control that stands for one — a dashboard tile, a saved view. */
|
|
28
|
+
applySetting: (setting: QuerySetting) => void;
|
|
29
|
+
}
|
|
30
|
+
/** A setting whose thunk has been read, which is the only form the store and the wire accept. */
|
|
31
|
+
export interface ResolvedQuerySetting {
|
|
32
|
+
queryKey: string;
|
|
33
|
+
args: unknown[];
|
|
27
34
|
}
|
|
28
35
|
interface QueryMakerKeyProps {
|
|
29
36
|
className?: string;
|
|
@@ -37,6 +44,12 @@ interface QueryMakerArgsProps {
|
|
|
37
44
|
state: QueryMakerState;
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
/** A filter's args may be written as a thunk, so that an arg relative to now is read when the filter is applied. */
|
|
48
|
+
export const resolveQuerySetting = (setting: QuerySetting): ResolvedQuerySetting => ({
|
|
49
|
+
queryKey: setting.queryKey,
|
|
50
|
+
args: typeof setting.args === "function" ? setting.args() : (setting.args ?? []),
|
|
51
|
+
});
|
|
52
|
+
|
|
40
53
|
const isFillableArg = (arg: SerializedArg) => !arg.modelType;
|
|
41
54
|
const defaultArg = (arg: SerializedArg) => ((arg.arrDepth ?? 0) > 0 ? [] : null);
|
|
42
55
|
|
|
@@ -58,21 +71,20 @@ export const useQueryMaker = ({ slice, query, onApply }: QueryMakerProps): Query
|
|
|
58
71
|
const queryKeys = Object.entries(filterQuery)
|
|
59
72
|
.filter(([, args]) => args.every(isFillableArg))
|
|
60
73
|
.map(([queryKey]) => queryKey);
|
|
61
|
-
const [setting, setSetting] = useState<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
74
|
+
const [setting, setSetting] = useState<ResolvedQuerySetting>(
|
|
75
|
+
query ? resolveQuerySetting(query) : { queryKey: queryKeys[0] ?? "any", args: [] },
|
|
76
|
+
);
|
|
65
77
|
|
|
66
|
-
const applyRef = useRef<(setting:
|
|
78
|
+
const applyRef = useRef<(setting: ResolvedQuerySetting) => void>(() => undefined);
|
|
67
79
|
applyRef.current = (setting) => {
|
|
68
80
|
if (onApply) onApply(setting);
|
|
69
81
|
else void storeDo[`setQueryArgsOf${capitalize(sliceName)}`](setting.queryKey, setting.args);
|
|
70
82
|
};
|
|
71
83
|
|
|
72
|
-
const applyQuery = useDebounce((setting:
|
|
84
|
+
const applyQuery = useDebounce((setting: ResolvedQuerySetting) => {
|
|
73
85
|
applyRef.current(setting);
|
|
74
86
|
}, []);
|
|
75
|
-
const update = (setting:
|
|
87
|
+
const update = (setting: ResolvedQuerySetting) => {
|
|
76
88
|
setSetting(setting);
|
|
77
89
|
|
|
78
90
|
if (isReadyQuery(filterQuery[setting.queryKey] ?? [], setting.args)) applyQuery(setting);
|
|
@@ -88,6 +100,9 @@ export const useQueryMaker = ({ slice, query, onApply }: QueryMakerProps): Query
|
|
|
88
100
|
setArg: (idx, value) => {
|
|
89
101
|
update({ ...setting, args: args.map((arg, i) => (i === idx ? value : (setting.args[i] ?? defaultArg(arg)))) });
|
|
90
102
|
},
|
|
103
|
+
applySetting: (setting) => {
|
|
104
|
+
update(resolveQuerySetting(setting));
|
|
105
|
+
},
|
|
91
106
|
};
|
|
92
107
|
};
|
|
93
108
|
|
package/ui/Data/RefPicker.tsx
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { cn, fetch, usePage } from "akanjs/client";
|
|
3
3
|
import { capitalize } from "akanjs/common";
|
|
4
4
|
import { ConstantRegistry, labelOf } from "akanjs/constant";
|
|
5
|
-
import type { QuerySetting } from "akanjs/fetch";
|
|
6
5
|
import { useEffect, useState } from "react";
|
|
7
6
|
import { AiOutlineClose, AiOutlineSearch } from "react-icons/ai";
|
|
8
7
|
import { buttonRecipe } from "../Button";
|
|
@@ -10,7 +9,7 @@ import { Empty } from "../Empty";
|
|
|
10
9
|
import { Loading } from "../Loading";
|
|
11
10
|
import { Modal } from "../Modal";
|
|
12
11
|
import { dictLabel } from "./dataText";
|
|
13
|
-
import { isReadyQuery, QueryMakerArgs, QueryMakerKey, useQueryMaker } from "./QueryMaker";
|
|
12
|
+
import { isReadyQuery, QueryMakerArgs, QueryMakerKey, type ResolvedQuerySetting, useQueryMaker } from "./QueryMaker";
|
|
14
13
|
|
|
15
14
|
interface RefPickerProps {
|
|
16
15
|
className?: string;
|
|
@@ -39,10 +38,10 @@ export default function RefPicker({ className, refName, value, onChange }: RefPi
|
|
|
39
38
|
const filterQuery = fetch.filterQueryMap?.get(refName) ?? {};
|
|
40
39
|
|
|
41
40
|
const initialKey = filterQuery.bySearch ? "bySearch" : "any";
|
|
42
|
-
const initialQuery:
|
|
41
|
+
const initialQuery: ResolvedQuerySetting = { queryKey: initialKey, args: [] };
|
|
43
42
|
|
|
44
43
|
const [open, setOpen] = useState(false);
|
|
45
|
-
const [applied, setApplied] = useState<
|
|
44
|
+
const [applied, setApplied] = useState<ResolvedQuerySetting>(initialQuery);
|
|
46
45
|
const [page, setPage] = useState(1);
|
|
47
46
|
const [rows, setRows] = useState<RefRow[]>([]);
|
|
48
47
|
const [hasNext, setHasNext] = useState(false);
|
package/ui/DatePicker.tsx
CHANGED
|
@@ -1,189 +1,168 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { type Dayjs, dayjs } from "akanjs/base";
|
|
3
3
|
import { cn, msg } from "akanjs/client";
|
|
4
|
-
import {
|
|
5
|
-
import { type FocusEvent, useEffect, useRef } from "react";
|
|
4
|
+
import { type ChangeEvent, useEffect } from "react";
|
|
6
5
|
import { AiOutlineSwapRight } from "react-icons/ai";
|
|
7
6
|
|
|
8
7
|
import { inputRecipe } from "./recipe";
|
|
9
8
|
import { createOverridable } from "./UiOverride";
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
type NativeDateType = "date" | "datetime-local" | "time";
|
|
11
|
+
|
|
12
|
+
const valueFormat = { date: "YYYY-MM-DD", "datetime-local": "YYYY-MM-DDTHH:mm", time: "HH:mm" } as const;
|
|
13
|
+
|
|
14
|
+
const toInputValue = (value: Dayjs | null | undefined, type: NativeDateType) =>
|
|
15
|
+
value?.isValid() ? value.format(valueFormat[type]) : "";
|
|
16
|
+
|
|
17
|
+
/** `type="time"` reports a clock reading only, so the day comes from whatever the field already holds. */
|
|
18
|
+
const fromInputValue = (raw: string, type: NativeDateType, base: Dayjs) => {
|
|
19
|
+
if (!raw) return null;
|
|
20
|
+
const picked = type === "time" ? dayjs(`${base.format(valueFormat.date)}T${raw}`) : dayjs(raw);
|
|
21
|
+
return picked.isValid() ? picked : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface NativeDateInputProps {
|
|
25
|
+
className?: string;
|
|
26
|
+
type: NativeDateType;
|
|
27
|
+
value?: Dayjs | null;
|
|
28
|
+
min?: Dayjs | null;
|
|
29
|
+
max?: Dayjs | null;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
32
|
+
onChange: (value: Dayjs) => void;
|
|
33
|
+
}
|
|
34
|
+
const NativeDateInput = ({
|
|
35
|
+
className,
|
|
36
|
+
type,
|
|
37
|
+
value,
|
|
38
|
+
min,
|
|
39
|
+
max,
|
|
40
|
+
disabled,
|
|
41
|
+
disabledDate,
|
|
42
|
+
onChange,
|
|
43
|
+
}: NativeDateInputProps) => {
|
|
44
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
45
|
+
const picked = fromInputValue(event.target.value, type, value ?? dayjs());
|
|
46
|
+
if (!picked || disabledDate?.(picked)) {
|
|
47
|
+
msg.warning("base.selectDateError");
|
|
48
|
+
|
|
49
|
+
event.target.value = toInputValue(value, type);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
onChange(picked);
|
|
53
|
+
};
|
|
54
|
+
return (
|
|
55
|
+
<input
|
|
56
|
+
className={className}
|
|
57
|
+
type={type}
|
|
58
|
+
value={toInputValue(value, type)}
|
|
59
|
+
min={min ? toInputValue(min, type) : undefined}
|
|
60
|
+
max={max ? toInputValue(max, type) : undefined}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
onChange={handleChange}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
13
66
|
|
|
14
|
-
const ReactDatePicker = lazy(
|
|
15
|
-
async () => {
|
|
16
|
-
await import(reactDatePickerStylePath);
|
|
17
|
-
return import(reactDatePickerPackage);
|
|
18
|
-
},
|
|
19
|
-
{ ssr: false },
|
|
20
|
-
);
|
|
21
67
|
export interface DatePickerProps {
|
|
68
|
+
className?: string;
|
|
22
69
|
value?: Dayjs | null;
|
|
23
70
|
onChange: (value: Dayjs | null) => void;
|
|
24
71
|
showTime?: boolean;
|
|
25
|
-
|
|
26
|
-
|
|
72
|
+
/** Earliest selectable value. The browser enforces it. */
|
|
73
|
+
min?: Dayjs | null;
|
|
74
|
+
/** Latest selectable value. The browser enforces it. */
|
|
75
|
+
max?: Dayjs | null;
|
|
76
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
27
77
|
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
28
|
-
className?: string;
|
|
29
|
-
placement?: "top" | "bottom" | "left" | "right";
|
|
30
78
|
defaultValue?: Dayjs;
|
|
31
79
|
}
|
|
32
80
|
|
|
33
81
|
const DefaultDatePicker = ({
|
|
82
|
+
className = "",
|
|
34
83
|
value,
|
|
35
84
|
onChange,
|
|
36
85
|
showTime,
|
|
37
|
-
|
|
38
|
-
|
|
86
|
+
min,
|
|
87
|
+
max,
|
|
39
88
|
disabledDate,
|
|
40
|
-
placement,
|
|
41
|
-
className = "",
|
|
42
89
|
defaultValue,
|
|
43
90
|
}: DatePickerProps) => {
|
|
44
|
-
const clickNum = useRef(0);
|
|
45
|
-
|
|
46
91
|
useEffect(() => {
|
|
47
|
-
if (defaultValue)
|
|
48
|
-
onChange(defaultValue);
|
|
49
|
-
}
|
|
92
|
+
if (defaultValue) onChange(defaultValue);
|
|
50
93
|
}, [defaultValue]);
|
|
51
94
|
|
|
52
|
-
const handleDateChange = (date?: Date | null) => {
|
|
53
|
-
if (!date) {
|
|
54
|
-
msg.warning("base.selectDateError");
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
onChange(dayjs(date));
|
|
58
|
-
};
|
|
59
|
-
|
|
60
95
|
return (
|
|
61
|
-
<
|
|
96
|
+
<NativeDateInput
|
|
62
97
|
className={inputRecipe({}, ["text-center", className])}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}}
|
|
70
|
-
onChange={handleDateChange}
|
|
71
|
-
showTimeSelect={showTime}
|
|
72
|
-
popperPlacement={placement}
|
|
73
|
-
timeIntervals={timeIntervals}
|
|
74
|
-
filterDate={(date: Date) => (!disabledDate?.(dayjs(date)) ? true : false)}
|
|
75
|
-
dateFormat={format}
|
|
98
|
+
type={showTime ? "datetime-local" : "date"}
|
|
99
|
+
value={value}
|
|
100
|
+
min={min}
|
|
101
|
+
max={max}
|
|
102
|
+
disabledDate={disabledDate}
|
|
103
|
+
onChange={onChange}
|
|
76
104
|
/>
|
|
77
105
|
);
|
|
78
106
|
};
|
|
79
107
|
|
|
80
108
|
export interface RangePickerProps {
|
|
109
|
+
className?: string;
|
|
81
110
|
value: [Dayjs | null, Dayjs | null];
|
|
82
111
|
onChange: (value: [Dayjs | null, Dayjs | null]) => void;
|
|
83
|
-
format?: string;
|
|
84
112
|
showTime?: boolean;
|
|
85
|
-
|
|
113
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
86
114
|
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
87
|
-
className?: string;
|
|
88
115
|
}
|
|
89
116
|
|
|
90
|
-
const DefaultRangePicker = ({
|
|
91
|
-
|
|
92
|
-
onChange,
|
|
93
|
-
format = "yyyy-MM-dd",
|
|
94
|
-
showTime,
|
|
95
|
-
timeIntervals = 10,
|
|
96
|
-
disabledDate,
|
|
97
|
-
className = "",
|
|
98
|
-
}: RangePickerProps) => {
|
|
99
|
-
const handleStartDateChange = (date?: Date | null) => {
|
|
100
|
-
if (!date) {
|
|
101
|
-
msg.warning("base.selectDateError");
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
onChange([dayjs(date), value[1] ?? dayjs()]);
|
|
105
|
-
};
|
|
106
|
-
const handleEndDateChange = (date?: Date | null) => {
|
|
107
|
-
if (!date) {
|
|
108
|
-
msg.warning("base.selectDateError");
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
onChange([value[0] ?? dayjs(), dayjs(date)]);
|
|
112
|
-
};
|
|
117
|
+
const DefaultRangePicker = ({ className = "", value, onChange, showTime, disabledDate }: RangePickerProps) => {
|
|
118
|
+
const type = showTime ? "datetime-local" : "date";
|
|
113
119
|
|
|
114
|
-
const
|
|
120
|
+
const inputClassName = "m-0 h-full w-full border-none bg-transparent p-3 text-center focus:outline-hidden";
|
|
115
121
|
return (
|
|
116
122
|
<div className={inputRecipe({}, ["flex h-full w-fit items-center gap-2 p-0", className])}>
|
|
117
|
-
<
|
|
118
|
-
className={
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
onChange={
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
filterDate={(date: Date) => (!disabledDate?.(dayjs(date)) ? true : false)}
|
|
127
|
-
dateFormat={format}
|
|
123
|
+
<NativeDateInput
|
|
124
|
+
className={inputClassName}
|
|
125
|
+
type={type}
|
|
126
|
+
value={value[0]}
|
|
127
|
+
max={value[1]}
|
|
128
|
+
disabledDate={disabledDate}
|
|
129
|
+
onChange={(start) => {
|
|
130
|
+
onChange([start, value[1] ?? dayjs()]);
|
|
131
|
+
}}
|
|
128
132
|
/>
|
|
129
133
|
<AiOutlineSwapRight className="text-3xl text-muted-foreground" />
|
|
130
|
-
<
|
|
131
|
-
className={
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
onChange={
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
filterDate={(date: Date) =>
|
|
140
|
-
!disabledDate?.(dayjs(date)) && value[0] && !dayjs(date).add(1, "day").isBefore(value[0]) ? true : false
|
|
141
|
-
}
|
|
142
|
-
filterTime={(time: Date) => !!value[0] && dayjs(time).isAfter(value[0])}
|
|
143
|
-
dateFormat={format}
|
|
134
|
+
<NativeDateInput
|
|
135
|
+
className={inputClassName}
|
|
136
|
+
type={type}
|
|
137
|
+
value={value[1]}
|
|
138
|
+
min={value[0]}
|
|
139
|
+
disabledDate={disabledDate}
|
|
140
|
+
onChange={(end) => {
|
|
141
|
+
onChange([value[0] ?? dayjs(), end]);
|
|
142
|
+
}}
|
|
144
143
|
/>
|
|
145
144
|
</div>
|
|
146
145
|
);
|
|
147
146
|
};
|
|
148
147
|
|
|
149
148
|
export interface TimePickerProps {
|
|
149
|
+
className?: string;
|
|
150
150
|
value: Dayjs | null;
|
|
151
151
|
onChange: (value: Dayjs) => void;
|
|
152
|
-
format?: string;
|
|
153
|
-
timeIntervals?: number;
|
|
154
|
-
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
155
|
-
className?: string;
|
|
156
152
|
disabled?: boolean;
|
|
153
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
154
|
+
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
157
155
|
}
|
|
158
156
|
|
|
159
|
-
const DefaultTimePicker = ({
|
|
160
|
-
disabled,
|
|
161
|
-
className,
|
|
162
|
-
value,
|
|
163
|
-
format = "HH:mm",
|
|
164
|
-
onChange,
|
|
165
|
-
timeIntervals = 10,
|
|
166
|
-
}: TimePickerProps) => {
|
|
167
|
-
const handleDateChange = (date?: Date | null) => {
|
|
168
|
-
if (!date) {
|
|
169
|
-
msg.warning("base.selectDateError");
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
onChange(dayjs(date));
|
|
173
|
-
};
|
|
174
|
-
|
|
157
|
+
const DefaultTimePicker = ({ className, value, onChange, disabled, disabledDate }: TimePickerProps) => {
|
|
175
158
|
return (
|
|
176
|
-
<
|
|
177
|
-
wrapperClassName="inline-block"
|
|
159
|
+
<NativeDateInput
|
|
178
160
|
className={cn("inline-block w-auto", className)}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
showTimeSelect
|
|
182
|
-
showTimeSelectOnly
|
|
183
|
-
timeIntervals={timeIntervals}
|
|
184
|
-
timeCaption="Time"
|
|
185
|
-
dateFormat={format}
|
|
161
|
+
type="time"
|
|
162
|
+
value={value}
|
|
186
163
|
disabled={disabled}
|
|
164
|
+
disabledDate={disabledDate}
|
|
165
|
+
onChange={onChange}
|
|
187
166
|
/>
|
|
188
167
|
);
|
|
189
168
|
};
|
|
@@ -194,9 +173,12 @@ const DatePickerBase = createOverridable("DatePicker", DefaultDatePicker);
|
|
|
194
173
|
* Date picker. `DatePicker`, `DatePicker.RangePicker`, and `DatePicker.TimePicker` each resolve to a
|
|
195
174
|
* route-scoped override when a `page/**\/_overrides.tsx` in the route's ancestry declares one (slots
|
|
196
175
|
* `DatePicker`, `DatePickerRangePicker`, `DatePickerTimePicker`).
|
|
176
|
+
*
|
|
177
|
+
* Each renders one native `<input type="date" | "datetime-local" | "time">`, so the calendar is the browser's
|
|
178
|
+
* own: an OS wheel on mobile, keyboard entry everywhere, nothing shipped in the bundle. What that costs is a
|
|
179
|
+
* themed popup, a chosen display format, and per-day disabling — an app needing any of those overrides the slot.
|
|
197
180
|
*/
|
|
198
181
|
export const DatePicker = Object.assign(DatePickerBase, {
|
|
199
182
|
RangePicker: createOverridable("DatePickerRangePicker", DefaultRangePicker),
|
|
200
183
|
TimePicker: createOverridable("DatePickerTimePicker", DefaultTimePicker),
|
|
201
184
|
});
|
|
202
|
-
|
package/ui/Model/AdminPanel.tsx
CHANGED
|
@@ -17,8 +17,13 @@ interface AdminPanelProps<T extends string, State, Input, Full extends { id: str
|
|
|
17
17
|
template?: any;
|
|
18
18
|
unit?: any;
|
|
19
19
|
view?: any;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Overrides the filter a summary column applies. A column left out still narrows the listing when its own
|
|
22
|
+
* field declares one with `.meta(...)`.
|
|
23
|
+
*/
|
|
21
24
|
queryMap?: { [column: string]: QuerySetting };
|
|
25
|
+
/** Model whose fields `summaryColumns` name. Defaults to the `summary` model the state key already implies. */
|
|
26
|
+
summaryRefName?: string;
|
|
22
27
|
/** Keys of the app's `summary` state shown as dashboard tiles above the list. */
|
|
23
28
|
summaryColumns?: string[];
|
|
24
29
|
/** Model insight keys shown above the list. The header already carries the total count. */
|
|
@@ -35,6 +40,7 @@ export default function AdminPanel<
|
|
|
35
40
|
slice,
|
|
36
41
|
components,
|
|
37
42
|
queryMap,
|
|
43
|
+
summaryRefName,
|
|
38
44
|
template,
|
|
39
45
|
unit,
|
|
40
46
|
view,
|
|
@@ -45,12 +51,15 @@ export default function AdminPanel<
|
|
|
45
51
|
),
|
|
46
52
|
|
|
47
53
|
renderDashboard = summaryColumns?.length
|
|
48
|
-
? ({ summary, hidePresents }) => (
|
|
54
|
+
? ({ summary, hidePresents, onSelect, queryKey }) => (
|
|
49
55
|
<Data.Dashboard
|
|
50
56
|
summary={summary}
|
|
51
57
|
slice={slice}
|
|
52
58
|
columns={summaryColumns}
|
|
53
59
|
queryMap={queryMap}
|
|
60
|
+
summaryRefName={summaryRefName}
|
|
61
|
+
onSelect={onSelect}
|
|
62
|
+
queryKey={queryKey}
|
|
54
63
|
hidePresents={hidePresents}
|
|
55
64
|
/>
|
|
56
65
|
)
|
package/ui/styles.css
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
기본 theme="css"는 data-theme 미부착 → :root 가 기본(dark). [data-theme="light"] override. */
|
|
15
15
|
:root,
|
|
16
16
|
[data-theme="dark"] {
|
|
17
|
+
/* Native controls the app does not paint — the date/time field's calendar popup, scrollbars, the caret —
|
|
18
|
+
take their colours from this and nothing else. */
|
|
19
|
+
color-scheme: dark;
|
|
17
20
|
--background: #0a0a0a;
|
|
18
21
|
--foreground: #fafafa;
|
|
19
22
|
--primary: #fafafa;
|
|
@@ -46,6 +49,7 @@
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
[data-theme="light"] {
|
|
52
|
+
color-scheme: light;
|
|
49
53
|
--background: #ffffff;
|
|
50
54
|
--foreground: #0a0a0a;
|
|
51
55
|
--primary: #171717;
|