feat: add scoped shortcuts
This commit is contained in:
@@ -11,7 +11,7 @@ import type {
|
|||||||
TextInputProps,
|
TextInputProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useTerminalDimensions } from "@opentui/react";
|
import { useTerminalDimensions } from "@opentui/react";
|
||||||
import { RGBA } from "@opentui/core";
|
import { BorderSides, RGBA } from "@opentui/core";
|
||||||
import { platform } from "node:os";
|
import { platform } from "node:os";
|
||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
|
|
||||||
@@ -57,15 +57,36 @@ export function View({ children, style }: ViewProps) {
|
|||||||
? typeof style.backgroundColor == "string"
|
? typeof style.backgroundColor == "string"
|
||||||
? style.backgroundColor.startsWith("rgba(")
|
? style.backgroundColor.startsWith("rgba(")
|
||||||
? (() => {
|
? (() => {
|
||||||
const parts = style.backgroundColor.split("(")[1].split(")")[0];
|
const parts = style.backgroundColor.split("(")[1].split(")")[0];
|
||||||
const [r, g, b, a] = parts.split(",").map(parseFloat);
|
const [r, g, b, a] = parts.split(",").map(parseFloat);
|
||||||
return RGBA.fromInts(r, g, b, a * 255);
|
return RGBA.fromInts(r, g, b, a * 255);
|
||||||
})()
|
})()
|
||||||
: style.backgroundColor
|
: style.backgroundColor
|
||||||
: undefined
|
: undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const padding = attr(style, "padding", "number");
|
const padding = attr(style, "padding", "number");
|
||||||
|
const paddingTop = attr(style, "paddingTop", "number");
|
||||||
|
const paddingLeft = attr(style, "paddingLeft", "number");
|
||||||
|
const paddingBottom = attr(style, "paddingBottom", "number");
|
||||||
|
const paddingRight = attr(style, "paddingRight", "number");
|
||||||
|
const gap = attr(style, "gap", "number");
|
||||||
|
|
||||||
|
const borderBottomWidth = attr(style, "borderBottomWidth", "number");
|
||||||
|
const borderTopWidth = attr(style, "borderTopWidth", "number");
|
||||||
|
const borderLeftWidth = attr(style, "borderLeftWidth", "number");
|
||||||
|
const borderRightWidth = attr(style, "borderRightWidth", "number");
|
||||||
|
|
||||||
|
const borderBottomColor = attr(style, "borderBottomColor", "string");
|
||||||
|
const borderTopColor = attr(style, "borderTopColor", "string");
|
||||||
|
const borderLeftColor = attr(style, "borderLeftColor", "string");
|
||||||
|
const borderRightColor = attr(style, "borderRightColor", "string");
|
||||||
|
|
||||||
|
const borderColor = attr(style, "borderColor", "string");
|
||||||
|
|
||||||
|
const top = attr(style, "top", "number");
|
||||||
|
|
||||||
|
const width = attr(style, "width", "number");
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
overflow: attr(style, "overflow", "string"),
|
overflow: attr(style, "overflow", "string"),
|
||||||
@@ -75,7 +96,6 @@ export function View({ children, style }: ViewProps) {
|
|||||||
justifyContent: attr(style, "justifyContent", "string"),
|
justifyContent: attr(style, "justifyContent", "string"),
|
||||||
flexShrink: attr(style, "flexShrink", "number"),
|
flexShrink: attr(style, "flexShrink", "number"),
|
||||||
flexDirection: attr(style, "flexDirection", "string"),
|
flexDirection: attr(style, "flexDirection", "string"),
|
||||||
top: attr(style, "top", "number"),
|
|
||||||
zIndex: attr(style, "zIndex", "number"),
|
zIndex: attr(style, "zIndex", "number"),
|
||||||
left: attr(style, "left", "number"),
|
left: attr(style, "left", "number"),
|
||||||
right: attr(style, "right", "number"),
|
right: attr(style, "right", "number"),
|
||||||
@@ -84,13 +104,40 @@ export function View({ children, style }: ViewProps) {
|
|||||||
attr(style, "flex", "number") || attr(style, "flexGrow", "number"),
|
attr(style, "flex", "number") || attr(style, "flexGrow", "number"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const border = (() => {
|
||||||
|
const sides: BorderSides[] = [];
|
||||||
|
if (borderBottomWidth) sides.push("bottom");
|
||||||
|
if (borderTopWidth) sides.push("top");
|
||||||
|
if (borderLeftWidth) sides.push("left");
|
||||||
|
if (borderRightWidth) sides.push("right");
|
||||||
|
if (!sides.length) return undefined;
|
||||||
|
return sides;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
backgroundColor={bg}
|
backgroundColor={bg}
|
||||||
paddingTop={padding && Math.round(padding / RATIO_HEIGHT)}
|
paddingTop={
|
||||||
paddingBottom={padding && Math.round(padding / RATIO_HEIGHT)}
|
(paddingTop && Math.round(paddingTop / RATIO_HEIGHT)) ||
|
||||||
paddingLeft={padding && Math.round(padding / RATIO_WIDTH)}
|
(padding && Math.round(padding / RATIO_HEIGHT))
|
||||||
paddingRight={padding && Math.round(padding / RATIO_WIDTH)}
|
}
|
||||||
|
paddingBottom={
|
||||||
|
(paddingBottom && Math.round(paddingBottom / RATIO_HEIGHT)) ||
|
||||||
|
(padding && Math.round(padding / RATIO_HEIGHT))
|
||||||
|
}
|
||||||
|
paddingLeft={
|
||||||
|
(paddingLeft && Math.round(paddingLeft / RATIO_WIDTH)) ||
|
||||||
|
(padding && Math.round(padding / RATIO_WIDTH))
|
||||||
|
}
|
||||||
|
paddingRight={
|
||||||
|
(paddingRight && Math.round(paddingRight / RATIO_WIDTH)) ||
|
||||||
|
(padding && Math.round(padding / RATIO_WIDTH))
|
||||||
|
}
|
||||||
|
gap={gap && Math.round(gap / RATIO_HEIGHT)}
|
||||||
|
border={border}
|
||||||
|
borderColor={borderColor}
|
||||||
|
width={width && Math.round(width / RATIO_WIDTH)}
|
||||||
|
top={top && Math.round(top / RATIO_HEIGHT)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -108,10 +155,10 @@ export function Pressable({
|
|||||||
? typeof style.backgroundColor == "string"
|
? typeof style.backgroundColor == "string"
|
||||||
? style.backgroundColor.startsWith("rgba(")
|
? style.backgroundColor.startsWith("rgba(")
|
||||||
? (() => {
|
? (() => {
|
||||||
const parts = style.backgroundColor.split("(")[1].split(")")[0];
|
const parts = style.backgroundColor.split("(")[1].split(")")[0];
|
||||||
const [r, g, b, a] = parts.split(",").map(parseFloat);
|
const [r, g, b, a] = parts.split(",").map(parseFloat);
|
||||||
return RGBA.fromInts(r, g, b, a * 255);
|
return RGBA.fromInts(r, g, b, a * 255);
|
||||||
})()
|
})()
|
||||||
: style.backgroundColor
|
: style.backgroundColor
|
||||||
: undefined
|
: undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -175,9 +222,9 @@ export function Pressable({
|
|||||||
onMouseDown={
|
onMouseDown={
|
||||||
onPress
|
onPress
|
||||||
? (_event) => {
|
? (_event) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
onPress();
|
onPress();
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
backgroundColor={bg}
|
backgroundColor={bg}
|
||||||
@@ -234,7 +281,8 @@ export function TextInput({
|
|||||||
}: TextInputProps) {
|
}: TextInputProps) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
width={20}
|
minWidth={20}
|
||||||
|
minHeight={1}
|
||||||
backgroundColor="white"
|
backgroundColor="white"
|
||||||
textColor="black"
|
textColor="black"
|
||||||
focused={true}
|
focused={true}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Transaction } from "@rocicorp/zero";
|
import type { Transaction } from "@rocicorp/zero";
|
||||||
import { authDataSchema, type AuthData } from "./auth";
|
import { authDataSchema, type AuthData } from "./auth";
|
||||||
import { type Schema } from "./zero-schema.gen";
|
import { type Category, type Schema } from "./zero-schema.gen";
|
||||||
import { isLoggedIn } from "./zql";
|
import { isLoggedIn } from "./zql";
|
||||||
|
|
||||||
type Tx = Transaction<Schema>;
|
type Tx = Transaction<Schema>;
|
||||||
@@ -8,10 +8,10 @@ type Tx = Transaction<Schema>;
|
|||||||
export function createMutators(authData: AuthData | null) {
|
export function createMutators(authData: AuthData | null) {
|
||||||
return {
|
return {
|
||||||
link: {
|
link: {
|
||||||
async create() { },
|
async create() {},
|
||||||
async get(tx: Tx, { link_token }: { link_token: string }) { },
|
async get(tx: Tx, { link_token }: { link_token: string }) {},
|
||||||
async updateTransactions() { },
|
async updateTransactions() {},
|
||||||
async updateBalences() { },
|
async updateBalences() {},
|
||||||
async deleteAccounts(tx: Tx, { accountIds }: { accountIds: string[] }) {
|
async deleteAccounts(tx: Tx, { accountIds }: { accountIds: string[] }) {
|
||||||
isLoggedIn(authData);
|
isLoggedIn(authData);
|
||||||
for (const id of accountIds) {
|
for (const id of accountIds) {
|
||||||
@@ -74,15 +74,29 @@ export function createMutators(authData: AuthData | null) {
|
|||||||
id,
|
id,
|
||||||
budgetId,
|
budgetId,
|
||||||
order,
|
order,
|
||||||
}: { id: string; budgetId: string; order?: number },
|
}: { id: string; budgetId: string; order: number },
|
||||||
) {
|
) {
|
||||||
isLoggedIn(authData);
|
isLoggedIn(authData);
|
||||||
|
|
||||||
|
if (order != undefined) {
|
||||||
|
const after = await tx.query.category
|
||||||
|
.where("budgetId", "=", budgetId)
|
||||||
|
.where("order", ">", order);
|
||||||
|
|
||||||
|
after.forEach((item) => {
|
||||||
|
tx.mutate.category.update({
|
||||||
|
id: item.id,
|
||||||
|
order: item.order + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
tx.mutate.category.insert({
|
tx.mutate.category.insert({
|
||||||
id,
|
id,
|
||||||
budgetId,
|
budgetId,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
every: "week",
|
every: "week",
|
||||||
order: order || 0,
|
order: order + 1,
|
||||||
label: "My category",
|
label: "My category",
|
||||||
color: "#f06",
|
color: "#f06",
|
||||||
createdBy: authData.user.id,
|
createdBy: authData.user.id,
|
||||||
@@ -90,20 +104,46 @@ export function createMutators(authData: AuthData | null) {
|
|||||||
},
|
},
|
||||||
async deleteCategory(tx: Tx, { id }: { id: string }) {
|
async deleteCategory(tx: Tx, { id }: { id: string }) {
|
||||||
isLoggedIn(authData);
|
isLoggedIn(authData);
|
||||||
|
const item = await tx.query.category.where("id", "=", id).one();
|
||||||
|
if (!item) throw Error("Item does not exist");
|
||||||
tx.mutate.category.update({
|
tx.mutate.category.update({
|
||||||
id,
|
id,
|
||||||
removedAt: new Date().getTime(),
|
removedAt: new Date().getTime(),
|
||||||
removedBy: authData.user.id,
|
removedBy: authData.user.id,
|
||||||
});
|
});
|
||||||
|
const after = await tx.query.category
|
||||||
|
.where("budgetId", "=", item.budgetId)
|
||||||
|
.where("order", ">", item.order)
|
||||||
|
.run();
|
||||||
|
for (const item of after) {
|
||||||
|
tx.mutate.category.update({ id: item.id, order: item.order - 1 });
|
||||||
|
}
|
||||||
|
// after.forEach((item) => {
|
||||||
|
// });
|
||||||
},
|
},
|
||||||
async updateCategory(
|
async updateCategory(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
{ id, label }: { id: string; label: string },
|
{
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
order,
|
||||||
|
amount,
|
||||||
|
every,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
order?: number;
|
||||||
|
amount?: number;
|
||||||
|
every?: Category["every"];
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
isLoggedIn(authData);
|
isLoggedIn(authData);
|
||||||
tx.mutate.category.update({
|
tx.mutate.category.update({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
|
order,
|
||||||
|
amount,
|
||||||
|
every,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const queries = {
|
|||||||
isLoggedIn(authData);
|
isLoggedIn(authData);
|
||||||
return builder.budget
|
return builder.budget
|
||||||
.related("categories", (q) =>
|
.related("categories", (q) =>
|
||||||
q.where("removedAt", "IS", null).orderBy("order", "desc"),
|
q.where("removedAt", "IS", null).orderBy("order", "asc"),
|
||||||
)
|
)
|
||||||
.limit(10);
|
.limit(10);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface DialogState {
|
|||||||
close?: () => void;
|
close?: () => void;
|
||||||
}
|
}
|
||||||
export const Context = createContext<DialogState>({
|
export const Context = createContext<DialogState>({
|
||||||
close: () => { },
|
close: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ProviderProps {
|
interface ProviderProps {
|
||||||
@@ -21,7 +21,7 @@ export function Provider({ children, visible, close }: ProviderProps) {
|
|||||||
{/* <Pressable onPress={() => close && close()} style={{ justifyContent: 'center', alignItems: 'center', flex: 1, backgroundColor: 'rgba(0,0,0,0.2)', }}> */}
|
{/* <Pressable onPress={() => close && close()} style={{ justifyContent: 'center', alignItems: 'center', flex: 1, backgroundColor: 'rgba(0,0,0,0.2)', }}> */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
justifyContent: "center",
|
// justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "rgba(0,0,0,0.2)",
|
backgroundColor: "rgba(0,0,0,0.2)",
|
||||||
@@ -39,12 +39,10 @@ interface ContentProps {
|
|||||||
}
|
}
|
||||||
export function Content({ children }: ContentProps) {
|
export function Content({ children }: ContentProps) {
|
||||||
const { close } = use(Context);
|
const { close } = use(Context);
|
||||||
useShortcut("escape", () => close?.());
|
useShortcut("escape", () => close?.(), "dialog");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={{ backgroundColor: "white", alignItems: "center", top: 120 }}>
|
||||||
style={{ backgroundColor: "white", padding: 12, alignItems: "center" }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import {
|
import { createContext, use, useEffect, useState, type ReactNode } from "react";
|
||||||
createContext,
|
|
||||||
use,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import type { KeyEvent } from "@opentui/core";
|
|
||||||
import { useShortcut } from "../lib/shortcuts/hooks";
|
import { useShortcut } from "../lib/shortcuts/hooks";
|
||||||
import type { Key } from "../lib/shortcuts";
|
import type { Key } from "../lib/shortcuts";
|
||||||
|
|
||||||
const HEADER_COLOR = "#7158e2";
|
const HEADER_COLOR = "#7158e2";
|
||||||
const TABLE_COLORS = ["#ddd", "#eee"];
|
|
||||||
const SELECTED_COLOR = "#f7b730";
|
const COLORS = {
|
||||||
|
focused: "#ddd",
|
||||||
|
selected: "#eaebf6",
|
||||||
|
focused_selected: "#d5d7ef",
|
||||||
|
};
|
||||||
|
|
||||||
const EXTRA = 5;
|
const EXTRA = 5;
|
||||||
|
|
||||||
@@ -24,7 +20,7 @@ interface TableState {
|
|||||||
columns: Column[];
|
columns: Column[];
|
||||||
columnMap: Map<string, number>;
|
columnMap: Map<string, number>;
|
||||||
idx: number;
|
idx: number;
|
||||||
selectedFrom: number | undefined;
|
selectedIdx: Set<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITAL_STATE = {
|
const INITAL_STATE = {
|
||||||
@@ -32,7 +28,7 @@ const INITAL_STATE = {
|
|||||||
columns: [],
|
columns: [],
|
||||||
columnMap: new Map(),
|
columnMap: new Map(),
|
||||||
idx: 0,
|
idx: 0,
|
||||||
selectedFrom: undefined,
|
selectedIdx: new Set(),
|
||||||
} satisfies TableState;
|
} satisfies TableState;
|
||||||
|
|
||||||
export const Context = createContext<TableState>(INITAL_STATE);
|
export const Context = createContext<TableState>(INITAL_STATE);
|
||||||
@@ -69,7 +65,7 @@ export function Provider<T extends ValidRecord>({
|
|||||||
shortcuts,
|
shortcuts,
|
||||||
}: ProviderProps<T>) {
|
}: ProviderProps<T>) {
|
||||||
const [idx, setIdx] = useState(0);
|
const [idx, setIdx] = useState(0);
|
||||||
const [selectedFrom, setSelectedFrom] = useState<number>();
|
const [selectedIdx, setSelectedIdx] = useState(new Set<number>());
|
||||||
|
|
||||||
useShortcut("j", () => {
|
useShortcut("j", () => {
|
||||||
setIdx((prev) => Math.min(prev + 1, data.length - 1));
|
setIdx((prev) => Math.min(prev + 1, data.length - 1));
|
||||||
@@ -84,6 +80,17 @@ export function Provider<T extends ValidRecord>({
|
|||||||
setIdx((prev) => Math.max(prev - 1, 0));
|
setIdx((prev) => Math.max(prev - 1, 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useShortcut("escape", () => {
|
||||||
|
setSelectedIdx(new Set());
|
||||||
|
});
|
||||||
|
useShortcut("x", () => {
|
||||||
|
setSelectedIdx((last) => {
|
||||||
|
const newSelected = new Set(last);
|
||||||
|
newSelected.add(idx);
|
||||||
|
return newSelected;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIdx((prev) => Math.max(Math.min(prev, data.length - 1), 0));
|
setIdx((prev) => Math.max(Math.min(prev, data.length - 1), 0));
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -91,9 +98,9 @@ export function Provider<T extends ValidRecord>({
|
|||||||
if (shortcuts) {
|
if (shortcuts) {
|
||||||
for (const shortcut of shortcuts) {
|
for (const shortcut of shortcuts) {
|
||||||
useShortcut(shortcut.key, () => {
|
useShortcut(shortcut.key, () => {
|
||||||
const from = selectedFrom ? Math.min(idx, selectedFrom) : idx;
|
const selected = data.filter(
|
||||||
const to = selectedFrom ? Math.max(idx, selectedFrom) : idx;
|
(_, index) => idx == index || selectedIdx.has(index),
|
||||||
const selected = data.slice(from, to + 1);
|
);
|
||||||
shortcut.handler({ selected, index: idx });
|
shortcut.handler({ selected, index: idx });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -112,14 +119,14 @@ export function Provider<T extends ValidRecord>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Provider value={{ data, columns, columnMap, idx, selectedFrom }}>
|
<Context.Provider value={{ data, columns, columnMap, idx, selectedIdx }}>
|
||||||
{children}
|
{children}
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Body() {
|
export function Body() {
|
||||||
const { columns, data, columnMap, idx, selectedFrom } = use(Context);
|
const { columns, data, columnMap, idx, selectedIdx } = use(Context);
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View style={{ backgroundColor: HEADER_COLOR, flexDirection: "row" }}>
|
<View style={{ backgroundColor: HEADER_COLOR, flexDirection: "row" }}>
|
||||||
@@ -136,19 +143,21 @@ export function Body() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
{data.map((row, index) => {
|
{data.map((row, index) => {
|
||||||
const isSelected =
|
const isSelected = selectedIdx.has(index);
|
||||||
index == idx ||
|
const isFocused = index == idx;
|
||||||
(selectedFrom != undefined &&
|
|
||||||
((selectedFrom <= index && index <= idx) ||
|
|
||||||
(idx <= index && index <= selectedFrom)));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={index}
|
key={index}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isSelected
|
backgroundColor:
|
||||||
? SELECTED_COLOR
|
isSelected && isFocused
|
||||||
: TABLE_COLORS[index % 2],
|
? COLORS.focused_selected
|
||||||
|
: isFocused
|
||||||
|
? COLORS.focused
|
||||||
|
: isSelected
|
||||||
|
? COLORS.selected
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableRow
|
<TableRow
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { keysStore } from "./store";
|
import { keysStore, type ScopeKeys } from "./store";
|
||||||
|
|
||||||
export function ShortcutDebug() {
|
export function ShortcutDebug() {
|
||||||
const entries = useSyncExternalStore(
|
const entries = useSyncExternalStore(
|
||||||
@@ -19,14 +19,23 @@ export function ShortcutDebug() {
|
|||||||
padding: 10,
|
padding: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ color: "red", fontFamily: "mono" }}>Registered:</Text>
|
<Text style={{ color: "red", fontFamily: "mono" }}>Scopes:</Text>
|
||||||
<Text style={{ color: "red", fontFamily: "mono", textAlign: "right" }}>
|
{entries.map(([scope, keys]) => (
|
||||||
{entries
|
<ScopeView key={scope} scope={scope} keys={keys} />
|
||||||
.values()
|
))}
|
||||||
.map(([key, _]) => key)
|
|
||||||
.toArray()
|
|
||||||
.join(",")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScopeView({ scope, keys }: { scope: string; keys: ScopeKeys }) {
|
||||||
|
return (
|
||||||
|
<Text style={{ color: "red", fontFamily: "mono", textAlign: "right" }}>
|
||||||
|
{scope}:
|
||||||
|
{keys
|
||||||
|
.entries()
|
||||||
|
.map(([key, _]) => key)
|
||||||
|
.toArray()
|
||||||
|
.join(",")}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import { keysStore } from "./store";
|
|||||||
import type { Key } from "./types";
|
import type { Key } from "./types";
|
||||||
import { enforceKeyOptions } from "./util";
|
import { enforceKeyOptions } from "./util";
|
||||||
|
|
||||||
export const useShortcut = (key: Key, handler: () => void) => {
|
export const useShortcut = (
|
||||||
|
key: Key,
|
||||||
|
handler: () => void,
|
||||||
|
scope: string = "global",
|
||||||
|
) => {
|
||||||
const keyOptions = enforceKeyOptions(key);
|
const keyOptions = enforceKeyOptions(key);
|
||||||
const keyName = keyOptions.name;
|
const keyName = keyOptions.name;
|
||||||
const ref = useRef(handler);
|
const ref = useRef(handler);
|
||||||
ref.current = handler;
|
ref.current = handler;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
keysStore.register(keyName, ref);
|
keysStore.register(keyName, ref, scope);
|
||||||
return () => {
|
return () => {
|
||||||
keysStore.deregister(keyName);
|
keysStore.deregister(keyName, scope);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { type RefObject } from "react";
|
import { type RefObject } from "react";
|
||||||
|
|
||||||
// internal map
|
export type ScopeKeys = Map<string, RefObject<() => void>>;
|
||||||
const keys = new Map<string, RefObject<() => void>>();
|
|
||||||
|
|
||||||
// cached snapshot (stable reference)
|
// outer reactive container
|
||||||
let snapshot: [string, RefObject<() => void>][] = [];
|
const scopes = new Map<string, ScopeKeys>();
|
||||||
|
|
||||||
let listeners = new Set<() => void>();
|
// stable snapshot for subscribers
|
||||||
|
let snapshot: [string, ScopeKeys][] = [];
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
function emit() {
|
function emit() {
|
||||||
// refresh snapshot ONLY when keys actually change
|
// replace identity so subscribers re-render
|
||||||
snapshot = Array.from(keys.entries());
|
snapshot = Array.from(scopes.entries());
|
||||||
for (const fn of listeners) fn();
|
for (const fn of listeners) fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,20 +23,36 @@ export const keysStore = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getSnapshot() {
|
getSnapshot() {
|
||||||
return snapshot; // stable unless emit() ran
|
return snapshot;
|
||||||
},
|
},
|
||||||
|
|
||||||
register(key: string, ref: RefObject<() => void>) {
|
register(key: string, ref: RefObject<() => void>, scope: string) {
|
||||||
keys.set(key, ref);
|
const prev = scopes.get(scope);
|
||||||
|
const next = new Map(prev); // <-- important: new identity
|
||||||
|
next.set(key, ref);
|
||||||
|
|
||||||
|
scopes.set(scope, next); // <-- outer identity also changes
|
||||||
emit();
|
emit();
|
||||||
},
|
},
|
||||||
|
|
||||||
deregister(key: string) {
|
deregister(key: string, scope: string) {
|
||||||
keys.delete(key);
|
const prev = scopes.get(scope);
|
||||||
|
if (!prev) return;
|
||||||
|
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(key);
|
||||||
|
|
||||||
|
if (next.size === 0) {
|
||||||
|
scopes.delete(scope);
|
||||||
|
} else {
|
||||||
|
scopes.set(scope, next);
|
||||||
|
}
|
||||||
emit();
|
emit();
|
||||||
},
|
},
|
||||||
|
|
||||||
getHandler(key: string) {
|
getHandler(key: string) {
|
||||||
return keys.get(key)?.current;
|
// last scope wins — clarify this logic as needed
|
||||||
|
const last = Array.from(scopes.values()).at(-1);
|
||||||
|
return last?.get(key)?.current;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
import { useQuery, useZero } from "@rocicorp/zero/react";
|
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||||
import * as Table from "../components/Table";
|
import * as Table from "../components/Table";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import * as Dialog from "../components/Dialog";
|
import { RenameCategoryDialog } from "./budget/RenameCategoryDialog";
|
||||||
|
import {
|
||||||
|
UpdateCategoryAmountDialog,
|
||||||
|
type CategoryWithComputed,
|
||||||
|
type Updating,
|
||||||
|
} from "./budget/UpdateCategoryAmountDialog";
|
||||||
|
|
||||||
const COLUMNS: Table.Column[] = [
|
const COLUMNS: Table.Column[] = [
|
||||||
{ name: "label", label: "Name" },
|
{ name: "label", label: "Name" },
|
||||||
@@ -24,9 +29,9 @@ export function Budget() {
|
|||||||
const { auth } = use(RouterContext);
|
const { auth } = use(RouterContext);
|
||||||
const [budgets] = useQuery(queries.getBudgets(auth));
|
const [budgets] = useQuery(queries.getBudgets(auth));
|
||||||
const [renaming, setRenaming] = useState<Category>();
|
const [renaming, setRenaming] = useState<Category>();
|
||||||
|
const [editCategoryAmount, setEditCategoryAmount] = useState<Updating>();
|
||||||
|
|
||||||
const z = useZero<Schema, Mutators>();
|
const z = useZero<Schema, Mutators>();
|
||||||
const refText = useRef("");
|
|
||||||
|
|
||||||
const newBudget = () => {
|
const newBudget = () => {
|
||||||
const id = new Date().getTime().toString();
|
const id = new Date().getTime().toString();
|
||||||
@@ -60,8 +65,8 @@ export function Budget() {
|
|||||||
|
|
||||||
const data = budget.categories.slice().map((category) => {
|
const data = budget.categories.slice().map((category) => {
|
||||||
const { amount } = category;
|
const { amount } = category;
|
||||||
const week = amount;
|
const week = amount / 4;
|
||||||
const month = amount * 4;
|
const month = amount;
|
||||||
const year = amount * 12;
|
const year = amount * 12;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -79,7 +84,7 @@ export function Budget() {
|
|||||||
z.mutate.budget.createCategory({
|
z.mutate.budget.createCategory({
|
||||||
id,
|
id,
|
||||||
budgetId: budget.id,
|
budgetId: budget.id,
|
||||||
order: index - 1,
|
order: index,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,38 +100,37 @@ export function Budget() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onEditCategoryYearly = ({
|
||||||
|
selected,
|
||||||
|
}: { selected: CategoryWithComputed[] }) => {
|
||||||
|
for (const category of selected) {
|
||||||
|
setEditCategoryAmount({ category, every: "year" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEditCategoryMonthly = ({
|
||||||
|
selected,
|
||||||
|
}: { selected: CategoryWithComputed[] }) => {
|
||||||
|
for (const category of selected) {
|
||||||
|
setEditCategoryAmount({ category, every: "month" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEditCategoryWeekly = ({
|
||||||
|
selected,
|
||||||
|
}: { selected: CategoryWithComputed[] }) => {
|
||||||
|
for (const category of selected) {
|
||||||
|
setEditCategoryAmount({ category, every: "week" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog.Provider
|
<RenameCategoryDialog renaming={renaming} setRenaming={setRenaming} />
|
||||||
visible={renaming != undefined}
|
<UpdateCategoryAmountDialog
|
||||||
close={() => setRenaming(undefined)}
|
updating={editCategoryAmount}
|
||||||
>
|
setUpdating={setEditCategoryAmount}
|
||||||
<Dialog.Content>
|
/>
|
||||||
<Text style={{ fontFamily: "mono" }}>Edit Category</Text>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
style={{ fontFamily: "mono" }}
|
|
||||||
autoFocus
|
|
||||||
selectTextOnFocus
|
|
||||||
defaultValue={renaming?.label}
|
|
||||||
onChangeText={(t) => {
|
|
||||||
refText.current = t;
|
|
||||||
}}
|
|
||||||
onKeyPress={(e) => {
|
|
||||||
if (!renaming) return;
|
|
||||||
if (e.nativeEvent.key == "Enter") {
|
|
||||||
z.mutate.budget.updateCategory({
|
|
||||||
id: renaming.id,
|
|
||||||
label: refText.current,
|
|
||||||
});
|
|
||||||
setRenaming(undefined);
|
|
||||||
} else if (e.nativeEvent.key == "Escape") {
|
|
||||||
setRenaming(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Provider>
|
|
||||||
|
|
||||||
<View style={{ alignItems: "flex-start" }}>
|
<View style={{ alignItems: "flex-start" }}>
|
||||||
<Text style={{ fontFamily: "mono", textAlign: "left" }}>
|
<Text style={{ fontFamily: "mono", textAlign: "left" }}>
|
||||||
@@ -140,6 +144,9 @@ export function Budget() {
|
|||||||
{ key: "i", handler: newCategory },
|
{ key: "i", handler: newCategory },
|
||||||
{ key: "d", handler: deleteCategory },
|
{ key: "d", handler: deleteCategory },
|
||||||
{ key: "r", handler: renameCategory },
|
{ key: "r", handler: renameCategory },
|
||||||
|
{ key: "y", handler: onEditCategoryYearly },
|
||||||
|
{ key: "m", handler: onEditCategoryMonthly },
|
||||||
|
{ key: "w", handler: onEditCategoryWeekly },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
|
|||||||
77
packages/ui/src/budget/RenameCategoryDialog.tsx
Normal file
77
packages/ui/src/budget/RenameCategoryDialog.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import * as Dialog from "../../components/Dialog";
|
||||||
|
import { View, Text, TextInput } from "react-native";
|
||||||
|
import { type Category, type Mutators, type Schema } from "@money/shared";
|
||||||
|
import { useZero } from "@rocicorp/zero/react";
|
||||||
|
|
||||||
|
interface RenameCategoryDialogProps {
|
||||||
|
renaming: Category | undefined;
|
||||||
|
setRenaming: (v: Category | undefined) => void;
|
||||||
|
}
|
||||||
|
export function RenameCategoryDialog({
|
||||||
|
renaming,
|
||||||
|
setRenaming,
|
||||||
|
}: RenameCategoryDialogProps) {
|
||||||
|
const refText = useRef("");
|
||||||
|
const [renamingText, setRenamingText] = useState("");
|
||||||
|
|
||||||
|
const z = useZero<Schema, Mutators>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Provider
|
||||||
|
visible={renaming != undefined}
|
||||||
|
close={() => setRenaming(undefined)}
|
||||||
|
>
|
||||||
|
<Dialog.Content>
|
||||||
|
<View style={{ width: 400 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingLeft: 12,
|
||||||
|
paddingRight: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
style={{
|
||||||
|
fontFamily: "mono",
|
||||||
|
// @ts-ignore
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
selectTextOnFocus
|
||||||
|
defaultValue={renaming?.label}
|
||||||
|
onChangeText={(t) => {
|
||||||
|
refText.current = t;
|
||||||
|
setRenamingText(t);
|
||||||
|
}}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (!renaming) return;
|
||||||
|
if (e.nativeEvent.key == "Enter") {
|
||||||
|
if (refText.current.trim() == "")
|
||||||
|
return setRenaming(undefined);
|
||||||
|
z.mutate.budget.updateCategory({
|
||||||
|
id: renaming.id,
|
||||||
|
label: refText.current,
|
||||||
|
});
|
||||||
|
setRenaming(undefined);
|
||||||
|
} else if (e.nativeEvent.key == "Escape") {
|
||||||
|
setRenaming(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{ paddingLeft: 12, paddingRight: 12, paddingBottom: 12 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: "mono" }}>
|
||||||
|
→ Rename category to: {renamingText || renaming?.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: "mono" }}>→ Cancel</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
packages/ui/src/budget/UpdateCategoryAmountDialog.tsx
Normal file
107
packages/ui/src/budget/UpdateCategoryAmountDialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import * as Dialog from "../../components/Dialog";
|
||||||
|
import { View, Text, TextInput } from "react-native";
|
||||||
|
import { type Category, type Mutators, type Schema } from "@money/shared";
|
||||||
|
import { useZero } from "@rocicorp/zero/react";
|
||||||
|
|
||||||
|
export type Updating = {
|
||||||
|
category: CategoryWithComputed;
|
||||||
|
every: Category["every"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryWithComputed = Category & {
|
||||||
|
month: number;
|
||||||
|
year: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UpdateCategoryAmountDialogProps {
|
||||||
|
updating: Updating | undefined;
|
||||||
|
setUpdating: (v: Updating | undefined) => void;
|
||||||
|
}
|
||||||
|
export function UpdateCategoryAmountDialog({
|
||||||
|
updating,
|
||||||
|
setUpdating,
|
||||||
|
}: UpdateCategoryAmountDialogProps) {
|
||||||
|
const category = updating?.category;
|
||||||
|
const every = updating?.every;
|
||||||
|
|
||||||
|
const refText = useRef("");
|
||||||
|
const [amountText, setAmountText] = useState("");
|
||||||
|
|
||||||
|
const z = useZero<Schema, Mutators>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Provider
|
||||||
|
visible={category != undefined}
|
||||||
|
close={() => setUpdating(undefined)}
|
||||||
|
>
|
||||||
|
<Dialog.Content>
|
||||||
|
<View style={{ width: 400 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingLeft: 12,
|
||||||
|
paddingRight: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
style={{
|
||||||
|
fontFamily: "mono",
|
||||||
|
// @ts-ignore
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
selectTextOnFocus
|
||||||
|
defaultValue={category?.month.toString()}
|
||||||
|
onChangeText={(t) => {
|
||||||
|
refText.current = t;
|
||||||
|
setAmountText(t);
|
||||||
|
}}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (!category) return;
|
||||||
|
if (e.nativeEvent.key == "Enter") {
|
||||||
|
if (refText.current.trim() == "")
|
||||||
|
return setUpdating(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseFloat(refText.current);
|
||||||
|
|
||||||
|
const amount = (function () {
|
||||||
|
switch (every) {
|
||||||
|
case "year":
|
||||||
|
return parsed / 12;
|
||||||
|
case "month":
|
||||||
|
return parsed;
|
||||||
|
case "week":
|
||||||
|
return parsed * 4;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
z.mutate.budget.updateCategory({
|
||||||
|
id: category.id,
|
||||||
|
amount,
|
||||||
|
every,
|
||||||
|
});
|
||||||
|
setUpdating(undefined);
|
||||||
|
} catch (e) {}
|
||||||
|
} else if (e.nativeEvent.key == "Escape") {
|
||||||
|
setUpdating(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{ paddingLeft: 12, paddingRight: 12, paddingBottom: 12 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: "mono" }}>
|
||||||
|
→ Update monthly amount to: {amountText || category?.month}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: "mono" }}>→ Cancel</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,14 +39,14 @@ type Join<A extends string, B extends string> = `${A}${B}` extends `${infer X}`
|
|||||||
|
|
||||||
type ChildRoutes<Parent extends string, Children> = {
|
type ChildRoutes<Parent extends string, Children> = {
|
||||||
[K in keyof Children & string]: K extends `/${string}`
|
[K in keyof Children & string]: K extends `/${string}`
|
||||||
? Join<Parent, K>
|
? Join<Parent, K>
|
||||||
: never;
|
: never;
|
||||||
}[keyof Children & string];
|
}[keyof Children & string];
|
||||||
|
|
||||||
type Routes<T> = {
|
type Routes<T> = {
|
||||||
[K in keyof T & string]:
|
[K in keyof T & string]:
|
||||||
| K
|
| K
|
||||||
| (T[K] extends { children: infer C } ? ChildRoutes<K, C> : never);
|
| (T[K] extends { children: infer C } ? ChildRoutes<K, C> : never);
|
||||||
}[keyof T & string];
|
}[keyof T & string];
|
||||||
|
|
||||||
export type Route = Routes<typeof PAGES>;
|
export type Route = Routes<typeof PAGES>;
|
||||||
@@ -60,7 +60,7 @@ interface RouterContextType {
|
|||||||
export const RouterContext = createContext<RouterContextType>({
|
export const RouterContext = createContext<RouterContextType>({
|
||||||
auth: null,
|
auth: null,
|
||||||
route: "/",
|
route: "/",
|
||||||
setRoute: () => { },
|
setRoute: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
type AppProps = {
|
type AppProps = {
|
||||||
@@ -91,8 +91,8 @@ function Main() {
|
|||||||
route in PAGES
|
route in PAGES
|
||||||
? (route as keyof typeof PAGES)
|
? (route as keyof typeof PAGES)
|
||||||
: (Object.keys(PAGES)
|
: (Object.keys(PAGES)
|
||||||
.sort((a, b) => b.length - a.length)
|
.sort((a, b) => b.length - a.length)
|
||||||
.find((p) => route.startsWith(p)) as keyof typeof PAGES);
|
.find((p) => route.startsWith(p)) as keyof typeof PAGES);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ backgroundColor: "white", flex: 1 }}>
|
<View style={{ backgroundColor: "white", flex: 1 }}>
|
||||||
|
|||||||
@@ -40,13 +40,11 @@ export function Transactions() {
|
|||||||
<Table.Provider
|
<Table.Provider
|
||||||
data={items}
|
data={items}
|
||||||
columns={COLUMNS}
|
columns={COLUMNS}
|
||||||
onKey={(key) => {
|
shortcuts={[
|
||||||
if (key.name == "r" && key.shift) {
|
{ key: "r", handler: () => z.mutate.link.updateTransactions() },
|
||||||
z.mutate.link.updateTransactions();
|
]}
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ padding: 10, flex: 1 }}>
|
||||||
<View style={{ flexShrink: 0 }}>
|
<View style={{ flexShrink: 0 }}>
|
||||||
<Table.Body />
|
<Table.Body />
|
||||||
</View>
|
</View>
|
||||||
@@ -59,18 +57,16 @@ export function Transactions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Selected() {
|
function Selected() {
|
||||||
const { data, idx, selectedFrom } = use(Table.Context);
|
const { data, selectedIdx } = use(Table.Context);
|
||||||
|
|
||||||
if (selectedFrom == undefined)
|
if (selectedIdx.size == 0)
|
||||||
return (
|
return (
|
||||||
<View style={{ backgroundColor: "#ddd" }}>
|
<View style={{ backgroundColor: "#ddd" }}>
|
||||||
<Text style={{ fontFamily: "mono" }}>No items selected</Text>
|
<Text style={{ fontFamily: "mono" }}>No items selected</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const from = Math.min(idx, selectedFrom);
|
const selected = data.filter((_, i) => selectedIdx.has(i)) as Transaction[];
|
||||||
const to = Math.max(idx, selectedFrom);
|
|
||||||
const selected = data.slice(from, to + 1) as Transaction[];
|
|
||||||
const count = selected.length;
|
const count = selected.length;
|
||||||
const sum = selected.reduce((prev, curr) => prev + curr.amount, 0);
|
const sum = selected.reduce((prev, curr) => prev + curr.amount, 0);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user