feat: implement Positions settings component
parent
b7b26b09d5
commit
eb11cdae2d
@ -0,0 +1,413 @@
|
|||||||
|
import {
|
||||||
|
JSX,
|
||||||
|
Component,
|
||||||
|
For,
|
||||||
|
useContext,
|
||||||
|
startTransition,
|
||||||
|
Show,
|
||||||
|
splitProps,
|
||||||
|
} from "solid-js";
|
||||||
|
import { produce, unwrap } from "solid-js/store";
|
||||||
|
import { autoAnimate } from "~/directives/autoAnimate";
|
||||||
|
import { sortable } from "~/directives/sortable";
|
||||||
|
import {
|
||||||
|
LocalStoreContext,
|
||||||
|
Position,
|
||||||
|
POSITION_TYPE_AGILE,
|
||||||
|
POSITION_TYPE_QUANTITY,
|
||||||
|
StoreContext,
|
||||||
|
UiStoreContext,
|
||||||
|
} from "~/stores";
|
||||||
|
import AddIcon from "~icons/carbon/add-filled";
|
||||||
|
import DeleteIcon from "~icons/carbon/trash-can";
|
||||||
|
import DragVerticalIcon from "~icons/carbon/drag-vertical";
|
||||||
|
import PositionSettingsIcon from "~icons/carbon/settings-adjust";
|
||||||
|
import { Checkbox, TextArea, TextInput } from "../Form";
|
||||||
|
import { parseOptionalFloat } from "~/util";
|
||||||
|
import { MarkdownHelpLabel } from "../Markdown";
|
||||||
|
|
||||||
|
export const PositionsSettings: Component = () => {
|
||||||
|
const [state, setState] = useContext(StoreContext)!;
|
||||||
|
const [uiState, setUiState] = useContext(UiStoreContext)!;
|
||||||
|
|
||||||
|
autoAnimate;
|
||||||
|
sortable;
|
||||||
|
|
||||||
|
const AddPositionButton: Component<{ idx?: number }> = (props) => (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const id = Math.random();
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
produce((positions: Position[]) => {
|
||||||
|
const newPosition: Position = {
|
||||||
|
id,
|
||||||
|
enabled: true,
|
||||||
|
type: state.defaultPositionType,
|
||||||
|
name: "",
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
if (props.idx != null) {
|
||||||
|
positions.splice(props.idx, 0, newPosition);
|
||||||
|
} else {
|
||||||
|
positions.push(newPosition);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setUiState("selectedPosition", id);
|
||||||
|
}}
|
||||||
|
class="btn btn-xs btn-circle btn-outline btn-accent"
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 gap-4"
|
||||||
|
use:sortable={{
|
||||||
|
handle: ".handle",
|
||||||
|
dragClass: "sortable-drag-card",
|
||||||
|
ghostClass: "sortable-ghost-card",
|
||||||
|
filter: ".drag-disabled",
|
||||||
|
// forceFallback: true,
|
||||||
|
onEnd: (evt: any) => {
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
produce((positions: any[]) => {
|
||||||
|
const oldIndex = evt.oldIndex;
|
||||||
|
const newIndex = evt.newIndex;
|
||||||
|
let item = positions[oldIndex];
|
||||||
|
positions.splice(oldIndex, 1);
|
||||||
|
positions.splice(newIndex, 0, unwrap(item));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={state.positions}>
|
||||||
|
{(position, idx) => {
|
||||||
|
let nameInputRef: HTMLInputElement = undefined!;
|
||||||
|
let positionNumberInputRef: HTMLInputElement = undefined!;
|
||||||
|
let onEnterKeyClose: JSX.EventHandler<
|
||||||
|
HTMLInputElement,
|
||||||
|
KeyboardEvent
|
||||||
|
> = (e) => {
|
||||||
|
if (e.code !== "Enter") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUiState("selectedPosition", undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgileDropdown: Component<
|
||||||
|
{ selected: number } & JSX.HTMLAttributes<HTMLSelectElement>
|
||||||
|
> = (p) => {
|
||||||
|
const [props, rest] = splitProps(p, ["selected", "class"]);
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
class={
|
||||||
|
"flex-1 w-0 pl-1 pr-0 select indent-0 select-xs select-bordered rounded-none " +
|
||||||
|
props.class
|
||||||
|
}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<For each={[0, 1, 2, 3, 5, 8, 13]}>
|
||||||
|
{(v) => (
|
||||||
|
<option selected={v === props.selected}>{v}</option>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="indicator w-full">
|
||||||
|
<div class="indicator-item indicator-middle indicator-end flex items-center">
|
||||||
|
<div
|
||||||
|
title="Drag here"
|
||||||
|
class="rounded-sm bg-white p-1 py-3 shadow-sm border -ml-2 text-sm transition-transform cursor-pointer handle"
|
||||||
|
>
|
||||||
|
<DragVerticalIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-idx={idx()}
|
||||||
|
class="relative z-10 bg-white w-full mr-4 card rounded-lg card-bordered card-compact shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="card-body !p-2">
|
||||||
|
<div class="card-actions items-center justify-between gap-4 flex-nowrap">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex gap-2 overflow-hidden": true,
|
||||||
|
"line-through": !position.enabled,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="font-bold cursor-pointer"
|
||||||
|
onClick={async () => {
|
||||||
|
await startTransition(function () {
|
||||||
|
setUiState("selectedPosition", position.id);
|
||||||
|
});
|
||||||
|
if (positionNumberInputRef) {
|
||||||
|
positionNumberInputRef.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{position.number || idx() + 1}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="min-w-[50px] min-h-[1rem] cursor-pointer text-ellipsis overflow-hidden whitespace-nowrap"
|
||||||
|
onClick={async () => {
|
||||||
|
await startTransition(function () {
|
||||||
|
setUiState("selectedPosition", position.id);
|
||||||
|
});
|
||||||
|
if (nameInputRef) {
|
||||||
|
nameInputRef.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{position.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<AddPositionButton idx={idx()} />
|
||||||
|
<button
|
||||||
|
classList={{
|
||||||
|
"btn btn-xs btn-circle": true,
|
||||||
|
"btn-outline":
|
||||||
|
uiState.selectedPosition !== position.id,
|
||||||
|
"btn-active":
|
||||||
|
uiState.selectedPosition === position.id,
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
setUiState(
|
||||||
|
"selectedPosition",
|
||||||
|
uiState.selectedPosition !== position.id
|
||||||
|
? position.id
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PositionSettingsIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
produce((positions: Position[]) =>
|
||||||
|
positions.splice(idx(), 1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
class="btn btn-xs btn-circle btn-outline btn-error"
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-1">
|
||||||
|
<div class="shrink form-control">
|
||||||
|
<div use:autoAnimate class="input-group input-group-xs">
|
||||||
|
<select
|
||||||
|
class="select select-xs select-bordered border-r-0"
|
||||||
|
onChange={(e) =>
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"type",
|
||||||
|
e.currentTarget.value as any
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For
|
||||||
|
each={[
|
||||||
|
[POSITION_TYPE_QUANTITY, "Menge"],
|
||||||
|
[POSITION_TYPE_AGILE, "Agile"],
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{([type, label]) => (
|
||||||
|
<option
|
||||||
|
value={type}
|
||||||
|
selected={position.type === type}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
<Show when={position.type === POSITION_TYPE_QUANTITY}>
|
||||||
|
<div class="flex-1">
|
||||||
|
<input
|
||||||
|
class="w-full input input-bordered input-xs"
|
||||||
|
value={
|
||||||
|
position.quantity === 0
|
||||||
|
? ""
|
||||||
|
: position.quantity
|
||||||
|
}
|
||||||
|
placeholder="Menge"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
onInput={(e) => {
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"quantity",
|
||||||
|
parseFloat(e.currentTarget.value) || 0
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={position.type === POSITION_TYPE_AGILE}>
|
||||||
|
<AgileDropdown
|
||||||
|
title="Story Points Minimum"
|
||||||
|
class="border-r-0"
|
||||||
|
onChange={(e) =>
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"agilePointsMin",
|
||||||
|
parseInt(e.currentTarget.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={position.agilePointsMin || 0}
|
||||||
|
/>
|
||||||
|
<AgileDropdown
|
||||||
|
title="Story Points Maximum"
|
||||||
|
onChange={(e) =>
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"agilePointsMax",
|
||||||
|
parseInt(e.currentTarget.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={position.agilePointsMax || 0}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
value={
|
||||||
|
position.itemPrice === 0 ? "" : position.itemPrice
|
||||||
|
}
|
||||||
|
label="Einzelpreis"
|
||||||
|
placeholder={
|
||||||
|
state.defaultItemPrice
|
||||||
|
? state.defaultItemPrice + ""
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
type="number"
|
||||||
|
onInput={(e) => {
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"itemPrice",
|
||||||
|
parseOptionalFloat(e.currentTarget.value)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
use:autoAnimate
|
||||||
|
class="col-span-2 grid grid-cols-2 gap-1"
|
||||||
|
>
|
||||||
|
<Show when={uiState.selectedPosition === position.id}>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<TextInput
|
||||||
|
ref={nameInputRef}
|
||||||
|
value={position.name}
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
onKeyDown={onEnterKeyClose}
|
||||||
|
onInput={(e) => {
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"name",
|
||||||
|
e.currentTarget.value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<TextInput
|
||||||
|
ref={positionNumberInputRef}
|
||||||
|
value={position.number}
|
||||||
|
label="Position"
|
||||||
|
onKeyDown={onEnterKeyClose}
|
||||||
|
onInput={(e) => {
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"number",
|
||||||
|
e.currentTarget.value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<TextInput
|
||||||
|
label="Aktionspreis"
|
||||||
|
type="number"
|
||||||
|
value={position.fixedDiscountPrice}
|
||||||
|
onInput={(e) =>
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"fixedDiscountPrice",
|
||||||
|
parseOptionalFloat(e.currentTarget.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<TextArea
|
||||||
|
label="Beschreibung"
|
||||||
|
labelSuffixJsx={<MarkdownHelpLabel />}
|
||||||
|
value={position.description}
|
||||||
|
onInput={(evt) =>
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"description",
|
||||||
|
evt.currentTarget.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={position.enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
setState(
|
||||||
|
"positions",
|
||||||
|
idx(),
|
||||||
|
"enabled",
|
||||||
|
e.currentTarget.checked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Position ist aktiv
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class={"p-3 flex justify-center drag-disabled"}>
|
||||||
|
<AddPositionButton />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
Loading…
Reference in New Issue