feat: implement settings Overlay component

master
Katja Lutz 2 years ago
parent eb11cdae2d
commit d5eff6095e

@ -0,0 +1,973 @@
import {
batch,
Component,
FlowComponent,
For,
Show,
useContext,
JSX,
startTransition,
createMemo,
onMount,
onCleanup,
} from "solid-js";
import { createStore, reconcile, unwrap } from "solid-js/store";
import { format, fromUnixTime } from "date-fns";
import z from "myzod";
import Big from "big.js";
import { generate } from "node-iso11649";
import { customAlphabet } from "nanoid";
import createAccordion from "../Accordion";
import { Checkbox, TextArea, TextInput, UnixDateInput } from "../Form";
import { autoAnimate } from "~/directives/autoAnimate";
import {
LocalStoreContext,
localStoreSchema,
POSITION_TYPE_AGILE,
POSITION_TYPE_QUANTITY,
PRINT_TYPE_CONFIRMATION,
PRINT_TYPE_INVOICE,
PRINT_TYPE_OFFER,
StoreContext,
storeSchema,
UiStoreContext,
} from "~/stores";
import { AddressData, isStructuredAddress } from "../Address";
import PositionsIcon from "~icons/carbon/show-data-cards";
import YouIcon from "~icons/carbon/face-wink";
import DesignIcon from "~icons/carbon/paint-brush";
import PrinterIcon from "~icons/carbon/printer";
import ProjectIcon from "~icons/carbon/product";
import DownloadIcon from "~icons/carbon/download";
import LoadIcon from "~icons/carbon/folder";
import LoadingSpinnerIcon from "~icons/icomoon-free/spinner9";
import ErrorIcon from "~icons/carbon/error";
import SuccessIcon from "~icons/carbon/checkmark-filled";
import CustomerIcon from "~icons/carbon/friendship";
import WarningIcon from "~icons/carbon/warning-alt-filled";
import GenerateIcon from "~icons/carbon/chemistry";
import { saveFile, selectLocalFiles, uploadFile } from "~/client/filesystem";
import { resetInput, sleep } from "~/util";
import { PositionsSettings } from "./Positions";
import Modal, { ModalCloseButton } from "../Modal";
import { createValidation } from "~/hooks/validation";
import { MarkdownHelpLabel } from "../Markdown";
const AccordionItemGrid: FlowComponent = (props) => {
return (
<div class="grid grid-cols-2 gap-3 gap-x-1 pb-3">{props.children}</div>
);
};
const AccordionItemEnd: Component = () => {
return <div class="h-1" />;
};
const AccordionItemDivider: FlowComponent = (props) => {
return <div class="divider">{props.children}</div>;
};
const SettingsOverlay: Component = () => {
const [state, setState] = useContext(StoreContext)!;
const [localState, setLocalState] = useContext(LocalStoreContext)!;
const [loadModal, setLoadModal] = createStore({
open: false,
loading: false,
errors: null as null | {
message?: JSX.Element;
parseErrors: { path: string; message: string }[];
},
});
const [uiState, setUiState] = useContext(UiStoreContext)!;
const [AccordionItem] = createAccordion(null);
autoAnimate;
const [DocumentValidationContext, documentDataForm] = createValidation();
const [YourDataValidationContext, yourDataForm] = createValidation();
const [CustomerValidationContext, customerDataForm] = createValidation();
const AddressInputs: Component<{
namePrefix?: string;
nameRequired?: boolean;
setter: (name: string, value: any) => void;
address: () => AddressData;
}> = (props) => {
const isStructured = createMemo(() => isStructuredAddress(props.address()));
const withPrefix = (name: string) =>
createMemo(
() => `${props.namePrefix && props.namePrefix + "_"}${name}`
)();
return (
<>
<div class="col-span-2">
<TextInput
name={withPrefix("name")}
label="Name"
maxLength={70}
required={props.nameRequired}
value={props.address().name}
onInput={(evt) => props.setter("name", evt.currentTarget.value)}
/>
</div>
<Show when={props.nameRequired || props.address().name}>
<TextInput
name={withPrefix("line1")}
label={isStructured() ? "Strasse" : "Linie 1"}
maxLength={70}
required
value={props.address().line1}
onInput={(evt) => props.setter("line1", evt.currentTarget.value)}
/>
<TextInput
name={withPrefix("line2")}
type={isStructured() ? "number" : "text"}
label={isStructured() ? "Nummer" : "Linie 2"}
maxLength={isStructured() ? undefined : 70}
max={isStructured() ? 9999999999999999 : undefined}
min={isStructured() ? 0 : undefined}
required
value={props.address().line2}
onInput={(evt) => props.setter("line2", evt.currentTarget.value)}
/>
<TextInput
name={withPrefix("zip")}
label="Plz"
type="number"
maxLength={16}
min="0"
value={props.address().zip}
onInput={(evt) =>
props.setter(
"zip",
parseInt(evt.currentTarget.value) || undefined
)
}
/>
<TextInput
name={withPrefix("city")}
label="Ort"
value={props.address().city}
onInput={(evt) => props.setter("city", evt.currentTarget.value)}
/>
</Show>
</>
);
};
const createCustomerAddressSetter = (alternative = false) => {
const addressField = alternative ? "alternativeAddress" : "debtorAddress";
return (name: any, value: any) => {
setState("customer", addressField, name, value);
};
};
const contactSetter = (name: any, value: any) => {
setLocalState("contact", name, value);
};
const fullWidthLabelWidth = "50%";
const FullWidthAccordionInput: Component<Parameters<typeof TextInput>[0]> = (
props
) => (
<div class="col-span-2">
<TextInput labelMinWidth={fullWidthLabelWidth} {...props} />
</div>
);
const saveProject = () => {
const fileContent = JSON.stringify(
{
state: unwrap(state),
localState: unwrap(localState),
},
null,
" "
);
saveFile(
`rappli-${
state.project.projectNumber.length
? state.project.projectNumber.replaceAll(" ", "-") + "-"
: ""
}${format(fromUnixTime(state.project.date), "yyyy-MM-dd")}.json`,
"application/json",
fileContent
);
};
const saveOnCtrlS = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === "s") {
e.preventDefault();
saveProject();
}
};
onMount(function () {
document.addEventListener("keydown", saveOnCtrlS);
});
onCleanup(function () {
document.removeEventListener("keydown", saveOnCtrlS);
});
return (
<>
<div class="print:hidden bg-white z-50 h-full fixed xxl:h-auto xxl:relative left-0 w-[480px] grid grid-rows-[1fr_auto] gap-4 p-3 transition-all shadow-none hover:shadow-2xl focus-within:shadow-2xl -translate-x-72 opacity-70 hover:opacity-100 focus-within:opacity-100 xxl:opacity-100 xxl:-translate-x-0 hover:translate-x-0 focus-within:translate-x-0 ">
<div class="overflow-y-scroll">
<AccordionItem
item={0}
activeTitleColor="text-violet-600"
label={
<>
<ProjectIcon />
Dokument
<Show when={!documentDataForm.valid}>
<WarningIcon class="text-error" />
</Show>
</>
}
>
{/* TODO: Add option for item price decimals */}
<DocumentValidationContext>
<AccordionItemGrid>
<FullWidthAccordionInput
label="Projekt Nr."
value={state.project.projectNumber}
onInput={(evt) =>
setState(
"project",
"projectNumber",
evt.currentTarget.value
)
}
/>
<FullWidthAccordionInput
label="Bestellungs Nr."
value={state.project.orderNumber}
onInput={(evt) =>
setState("project", "orderNumber", evt.currentTarget.value)
}
/>
<div class="col-span-2">
<UnixDateInput
required
labelMinWidth={fullWidthLabelWidth}
label="Datum"
value={state.project.date}
onInput={(v: any) => setState("project", "date", v)}
/>
</div>
<FullWidthAccordionInput
label="Lieferungs Nr."
value={state.project.deliveryNumber}
onInput={(evt) =>
setState(
"project",
"deliveryNumber",
evt.currentTarget.value
)
}
/>
<div class="col-span-2">
<UnixDateInput
labelMinWidth={fullWidthLabelWidth}
label="Lieferdatum"
value={state.project.deliveryDate}
onInput={(v: any) => setState("project", "deliveryDate", v)}
/>
</div>
<div class="col-span-2">
<div class="input-group input-group-sm">
<span style={{ "min-width": fullWidthLabelWidth }}>
Typ neuer Positionen
</span>
<select
class="flex-1 select select-sm select-bordered"
onChange={(e) =>
setState(
"defaultPositionType",
e.currentTarget.value as any
)
}
>
<For
each={[
[POSITION_TYPE_QUANTITY, "Menge"],
[POSITION_TYPE_AGILE, "Agile"],
]}
>
{([type, label]) => (
<option
selected={type === state.defaultPositionType}
value={type}
>
{label}
</option>
)}
</For>
</select>
</div>
</div>
<FullWidthAccordionInput
required
type="number"
label="Standard Einzelpreis"
value={state.defaultItemPrice}
onInput={(evt) =>
setState(
"defaultItemPrice",
parseFloat(evt.currentTarget.value) || 0
)
}
/>
<div class="col-span-2">
<TextArea
label="Einleitung"
labelSuffixJsx={<MarkdownHelpLabel />}
value={state.project.preface}
onInput={(evt) => {
setState("project", "preface", evt.currentTarget.value);
}}
/>
</div>
<div class="col-span-2">
<TextArea
label="Schlussbemerkung"
labelSuffixJsx={<MarkdownHelpLabel />}
value={state.project.conclusion}
onInput={(evt) =>
setState("project", "conclusion", evt.currentTarget.value)
}
/>
</div>
</AccordionItemGrid>
<AccordionItemEnd />
<AccordionItemDivider>QR Rechnung</AccordionItemDivider>
<AccordionItemGrid>
<FullWidthAccordionInput
vertical={true}
label="Referenz"
value={state.invoice.reference}
onInput={(evt) =>
setState("invoice", "reference", evt.currentTarget.value)
}
/>
<div class="col-span-2">
<button
class="btn btn-xs btn-block btn-accent gap-2"
onClick={() => {
let value = state.invoice.reference;
if (value.startsWith("RF")) {
return;
}
if (value === "") {
value = customAlphabet("1234567890", 21)() + "";
}
setState("invoice", "reference", generate(value));
}}
>
<GenerateIcon /> Kreditor Referenz generieren
</button>
</div>
<div class="col-span-2">
<TextInput
vertical={true}
maxLength={140}
label="Zusätzliche Informationen"
value={state.invoice.message}
onInput={(evt) =>
setState("invoice", "message", evt.currentTarget.value)
}
/>
</div>
</AccordionItemGrid>
<AccordionItemEnd />
<AccordionItemDivider>Agile</AccordionItemDivider>
<AccordionItemGrid>
<div class="col-span-2">
<TextInput
required
type="number"
label="Stunden pro Story Point"
onInput={(evt) =>
evt.currentTarget.value !== "" &&
setState(
"agileHoursPerStoryPoint",
parseFloat(evt.currentTarget.value)
)
}
onBlur={resetInput(0)}
value={state.agileHoursPerStoryPoint}
/>
</div>
<div class="col-span-2">
<div class="form-control">
<label class="label gap-8">
<span class="label-text font-bold">Risiko Faktor</span>
<div class="flex-1">
<input
class="range range-xs hover:range-accent"
required
type="range"
title="0 - 1: Wie wahrscheinlich ist der Schätzung?"
value={state.agileRiskFactor}
min="0.0"
max="1.0"
step="0.1"
onInput={(evt) =>
evt.currentTarget.value !== "" &&
setState(
"agileRiskFactor",
parseFloat(evt.currentTarget.value)
)
}
onBlur={resetInput(0)}
/>
<div class="w-full flex justify-between text-xs px-2 relative">
<span>
<div class="absolute left-0">Klein</div>&nbsp;
</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>
<div class="absolute text-right right-0">Gross</div>
&nbsp;
</span>
</div>
</div>
</label>
</div>
</div>
</AccordionItemGrid>
</DocumentValidationContext>
</AccordionItem>
<AccordionItem
item={1}
activeTitleColor="text-cyan-500"
label={
<>
<YouIcon />
Deine Angaben
<Show when={!yourDataForm.valid}>
<WarningIcon class="text-error" />
</Show>
</>
}
>
<YourDataValidationContext>
<AccordionItemDivider>Bank Verbindung</AccordionItemDivider>
<AccordionItemGrid>
<div class="col-span-2">
<TextInput
required
label="Iban"
value={localState.iban}
onInput={(evt) =>
setLocalState("iban", evt.currentTarget.value)
}
/>
</div>
<AddressInputs
namePrefix="creditor"
nameRequired={true}
setter={(name, value) => {
setLocalState("creditor", name as any, value);
}}
address={() => localState.creditor}
/>
</AccordionItemGrid>
<AccordionItemEnd />
<AccordionItemDivider>
Abweichene Adresse{" "}
<input
class="checkbox"
type="checkbox"
checked={localState.useCustomAddress}
onChange={(evt) =>
setLocalState("useCustomAddress", evt.currentTarget.checked)
}
/>
</AccordionItemDivider>
<div use:autoAnimate>
<Show when={localState.useCustomAddress}>
<AccordionItemGrid>
<AddressInputs
namePrefix="customAddress"
nameRequired={true}
setter={(name, value) => {
setLocalState("customAddress", name as any, value);
}}
address={() => localState.customAddress}
/>
</AccordionItemGrid>
</Show>
</div>
<AccordionItemEnd />
<AccordionItemDivider>Ansprechpartner</AccordionItemDivider>
<AccordionItemGrid>
<div class="col-span-2">
<TextInput
label="Name"
value={localState.contact?.name}
onInput={(evt) =>
contactSetter("name", evt.currentTarget.value)
}
/>
</div>
<TextInput
label="Telefon"
value={localState.contact?.phone}
onInput={(evt) =>
contactSetter("phone", evt.currentTarget.value)
}
/>
<TextInput
label="E-Mail"
value={localState.contact?.email}
type="email"
onInput={(evt) =>
contactSetter("email", evt.currentTarget.value)
}
/>
</AccordionItemGrid>
<AccordionItemEnd />
<AccordionItemDivider>Andere Angaben</AccordionItemDivider>
<AccordionItemGrid>
<FullWidthAccordionInput
vertical={true}
label="Zahlungsbedingungen"
value={localState.paymentTerms}
onInput={(evt) =>
setLocalState("paymentTerms", evt.currentTarget.value)
}
/>
<div class="col-span-2">
<TextInput
label="MwST-Nr."
value={localState.vatNumber}
onInput={(evt) =>
setLocalState("vatNumber", evt.currentTarget.value)
}
/>
</div>
<div class="col-span-2">
<TextInput
required
label="MwST-Satz"
type="number"
step="0.1"
suffix="%"
value={new Big(localState.vatRate).mul(100).toNumber()}
onInput={(evt) => {
evt.currentTarget.value !== "" &&
setLocalState(
"vatRate",
new Big(evt.currentTarget.value).div(100).toNumber()
);
}}
onBlur={resetInput(0)}
/>
</div>
</AccordionItemGrid>
<AccordionItemEnd />
<AccordionItemDivider>Corporate Design</AccordionItemDivider>
<AccordionItemGrid>
<div class="col-span-2">
<TextInput
label="Logo"
type="file"
class="file-input"
accept="image/png, image/jpeg, image/svg+xml"
onInput={async (evt) => {
if (!evt.currentTarget.files) {
return;
}
const file = evt.currentTarget.files[0];
if (!file) {
return;
}
const content = await uploadFile(file, "dataUrl");
if (!content) {
return;
}
const image = document.createElement("img");
image.src = content;
image.onload = function () {
setLocalState("logo", {
width: image.width,
height: image.height,
type: file.type,
url: content,
});
};
}}
/>
</div>
</AccordionItemGrid>
</YourDataValidationContext>
</AccordionItem>
<AccordionItem
item={2}
activeTitleColor="text-emerald-500"
label={
<>
<CustomerIcon /> Kunde
<Show when={!customerDataForm.valid}>
<WarningIcon class="text-error" />
</Show>
</>
}
>
<CustomerValidationContext>
<AccordionItemDivider>Bank Verbindung</AccordionItemDivider>
<AccordionItemGrid>
<AddressInputs
namePrefix="debtor"
setter={createCustomerAddressSetter()}
address={() => state.customer.debtorAddress}
/>
</AccordionItemGrid>
<AccordionItemEnd />
<AccordionItemDivider>
Abweichende Adresse{" "}
<input
class="checkbox"
type="checkbox"
checked={state.useCustomerAlternativeAddress}
onChange={(evt) =>
setState(
"useCustomerAlternativeAddress",
evt.currentTarget.checked
)
}
/>
</AccordionItemDivider>
<div use:autoAnimate>
<Show when={state.useCustomerAlternativeAddress}>
<AccordionItemGrid>
<AddressInputs
setter={createCustomerAddressSetter(true)}
address={() => state.customer.alternativeAddress}
/>
</AccordionItemGrid>
</Show>
</div>
<AccordionItemEnd />
<AccordionItemDivider>Andere Angaben</AccordionItemDivider>
<AccordionItemGrid>
<FullWidthAccordionInput
label="Kunden Nr."
value={state.customer.customerNumber}
onInput={(evt) =>
setState(
"customer",
"customerNumber",
evt.currentTarget.value
)
}
/>
<FullWidthAccordionInput
label="MwST-Nr."
value={state.customer.vatNumber}
onInput={(evt) =>
setState("customer", "vatNumber", evt.currentTarget.value)
}
/>
</AccordionItemGrid>
</CustomerValidationContext>
</AccordionItem>
<AccordionItem
item={3}
activeTitleColor="text-orange-400"
label={
<>
<PositionsIcon />
Positionen
<small>({state.positions.length})</small>
</>
}
>
<PositionsSettings />
</AccordionItem>
<AccordionItem
activeTitleColor="text-blue-500"
label={
<>
<DesignIcon />
Design
</>
}
item={4}
>
<AccordionItemGrid>
<div class="col-span-2">
<Checkbox
checked={localState.showLufraiWatermark}
onChange={(evt) =>
setLocalState(
"showLufraiWatermark",
evt.currentTarget.checked
)
}
>
Lufrai Wasserzeichen anzeigen
</Checkbox>
</div>
<div class="col-span-2">
<Checkbox
checked={state.fullWidthInvoice}
onChange={(evt) =>
setState("fullWidthInvoice", evt.currentTarget.checked)
}
>
Abstandlose QR Rechnung
</Checkbox>
</div>
</AccordionItemGrid>
</AccordionItem>
</div>
<div>
<div class="grid grid-cols-2 gap-2 mb-4">
<button
class="btn btn-sm btn-accent gap-2"
onClick={async () => {
const files = await selectLocalFiles([
".json",
"application/json",
]);
if (!files[0]) {
return;
}
setLoadModal("loading", true);
setLoadModal("errors", null);
await sleep(200);
setLoadModal("open", true);
const load = async () => {
const results = await Promise.all([
uploadFile(files[0]),
sleep(600),
]);
const content = results[0];
if (!content) {
setLoadModal("errors", {
message:
"Das Dokument ist leer! Bitte lade ein korrektes Dokument hoch.",
});
return;
}
let contentObject: any;
try {
contentObject = JSON.parse(content);
} catch (err) {
setLoadModal("errors", {
message: "Das Dokument hat kein gültiges Format.",
});
return;
}
// TODO: Run migrations
const schema = z
.object({
state: storeSchema,
localState: localStoreSchema,
})
.collectErrors();
try {
schema.parse(contentObject);
} catch (e: any) {
const message = "Das Dokument hat kein gültiges Format.";
let parseErrors = [{ path: ".", message: e.message }];
if (e.collectedErrors) {
parseErrors = Object.values(e.collectedErrors).map(
(error: any) => {
return {
path: error.path.join("."),
message: error.message,
};
}
);
}
setLoadModal("errors", {
message,
parseErrors,
});
return;
}
await startTransition(function () {
batch(() => {
setState(reconcile(contentObject.state));
setLocalState(reconcile(contentObject.localState));
});
});
};
const results = await Promise.all([sleep(1200), load()]);
setLoadModal("loading", false);
if (!loadModal.errors) {
await sleep(1100);
setLoadModal("open", false);
}
}}
>
<LoadIcon /> Laden
</button>
<button class="btn btn-sm btn-accent gap-2" onClick={saveProject}>
<DownloadIcon /> Speichern
</button>
</div>
<div class="form-control w-full">
<div class="input-group input-group-sm">
<select
class="w-1/2 select select-sm select-bordered"
onChange={(evt) => {
setUiState("printType", evt.currentTarget.value as any);
evt.currentTarget.blur();
}}
>
<For
each={[
[PRINT_TYPE_OFFER, "Offerte"],
[PRINT_TYPE_CONFIRMATION, "Bestätigung"],
[PRINT_TYPE_INVOICE, "Rechnung"],
]}
>
{([type, label]) => (
<option value={type} selected={type === uiState.printType}>
{label}
</option>
)}
</For>
</select>
<button
class="btn btn-sm btn-primary flex-1 gap-2"
onClick={() => window.print()}
>
<PrinterIcon /> Drucken
</button>
</div>
</div>
</div>
</div>
<Show when={true}>
<Modal open={loadModal.open}>
<Show when={!loadModal.loading && loadModal.errors}>
<ModalCloseButton
onClick={() => {
setLoadModal("open", false);
}}
/>
</Show>
<div use:autoAnimate class="flex flex-col items-center">
<Show when={!loadModal.loading}>
<Show when={loadModal.errors}>
<div class="flex items-center gap-2 text-xl text-error mb-10">
<ErrorIcon />
<div class="font-black">
Dokument konnte nicht geladen werden
</div>
</div>
<Show when={loadModal.errors?.message}>
<div class="text-5xl font-light text-error-content text-center">
{loadModal.errors?.message}
</div>
</Show>
<Show when={loadModal.errors?.parseErrors}>
<table class="mt-6 w-full table table-compact">
<thead>
<tr>
<th></th>
<th>JSON-Pfad</th>
<th>Fehler</th>
</tr>
</thead>
<tbody>
<For each={loadModal.errors?.parseErrors}>
{(error, idx) => (
<tr>
<th>{idx() + 1}</th>
<td class="break-all">{error.path}</td>
<td>{error.message}</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
<button
class="mt-8 btn"
onClick={() => {
setLoadModal("open", false);
setLoadModal("errors", null);
}}
>
Schliessen
</button>
</Show>
<Show when={!loadModal.errors}>
<div class="flex justify-center">
<div class="animate-pulse text-7xl text-success">
<SuccessIcon />
</div>
</div>
</Show>
</Show>
<Show when={loadModal.loading}>
<div class="flex justify-center">
<div class="animate-spin text-7xl text-primary">
<LoadingSpinnerIcon />
</div>
</div>
</Show>
</div>
</Modal>
</Show>
</>
);
};
export default SettingsOverlay;
Loading…
Cancel
Save