feat: implement settings Overlay component
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>
|
||||
</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>
|
||||
|
||||
</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…
Reference in New Issue