You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1100 lines
39 KiB
TypeScript
1100 lines
39 KiB
TypeScript
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, getUnixTime } 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,
|
|
NumberInput,
|
|
Select,
|
|
TextArea,
|
|
TextInput,
|
|
UnixDateInput,
|
|
} from "../Form";
|
|
import { autoAnimate } from "~/directives/autoAnimate";
|
|
import {
|
|
ADDRESS_LAYOUT_LEFT,
|
|
ADDRESS_LAYOUT_RIGHT,
|
|
CURRENT_VERSION,
|
|
LocalStoreContext,
|
|
localStoreSchema,
|
|
migrateInfoLog,
|
|
migrateLocalState,
|
|
migrateState,
|
|
POSITION_TYPE_AGILE,
|
|
POSITION_TYPE_QUANTITY,
|
|
PrintType,
|
|
printTypeSelectTitles,
|
|
printTypeTitles,
|
|
PRINT_TYPE_CONFIRMATION,
|
|
PRINT_TYPE_INVOICE,
|
|
PRINT_TYPE_LETTER,
|
|
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 DeleteIcon from "~icons/carbon/trash-can";
|
|
|
|
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="text"
|
|
label={isStructured() ? "Nummer" : "Linie 2"}
|
|
maxLength={isStructured() ? 16 : 70}
|
|
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
|
|
);
|
|
|
|
setUiState("lastSaved", getUnixTime(new Date()));
|
|
};
|
|
|
|
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);
|
|
});
|
|
|
|
const AccordionItemLabel: FlowComponent = (props) => (
|
|
<span class="overflow-hidden whitespace-nowrap hidden xxl:inline-flex group-hover:inline-flex group-focus-within:inline-flex gap-2">
|
|
{props.children}
|
|
</span>
|
|
);
|
|
|
|
const AccordionContent: FlowComponent = (props) => (
|
|
<div class="min-w-[300px]">{props.children}</div>
|
|
);
|
|
|
|
let logoInputEl: HTMLInputElement = undefined!;
|
|
|
|
return (
|
|
<>
|
|
<div class="group print:hidden hidden lg:grid bg-white z-50 h-full fixed xxl:h-auto xxl:relative left-0 xxl:w-[480px] grid-rows-[1fr_auto] gap-4 p-1 xxl:p-3 transition-all shadow hover:shadow-2xl focus-within:shadow-2xl outline outline-1 outline-slate-700/10 w-24 hover:w-[480px] focus-within:w-[480px]">
|
|
<div class="overflow-y-scroll">
|
|
<AccordionItem
|
|
item={0}
|
|
activeTitleColor="text-violet-600"
|
|
label={
|
|
<>
|
|
<ProjectIcon />
|
|
<AccordionItemLabel>Dokument</AccordionItemLabel>
|
|
<Show when={!documentDataForm.valid}>
|
|
<AccordionItemLabel>
|
|
<WarningIcon class="text-error" />
|
|
</AccordionItemLabel>
|
|
</Show>
|
|
</>
|
|
}
|
|
>
|
|
{/* TODO: Add option for item price decimals */}
|
|
<AccordionContent>
|
|
<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">
|
|
<Select
|
|
label="Typ neuer Positionen"
|
|
labelMinWidth={fullWidthLabelWidth}
|
|
title="Neue Positionen werden mit diesem Typ erzeugt."
|
|
value={state.defaultPositionType}
|
|
options={[
|
|
[POSITION_TYPE_QUANTITY, "Menge"],
|
|
[POSITION_TYPE_AGILE, "Agile"],
|
|
]}
|
|
onChange={(v) =>
|
|
setState("defaultPositionType", v as any)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div class="col-span-2">
|
|
<NumberInput
|
|
required
|
|
label="Standard Einzelpreis"
|
|
labelMinWidth={fullWidthLabelWidth}
|
|
value={state.defaultItemPrice}
|
|
onInput={(v) =>
|
|
v != null && setState("defaultItemPrice", v)
|
|
}
|
|
onBlur={resetInput(0)}
|
|
/>
|
|
</div>
|
|
|
|
<div class="col-span-2">
|
|
<TextInput
|
|
label="Titel"
|
|
labelMinWidth={fullWidthLabelWidth}
|
|
placeholder={
|
|
state.project.title
|
|
? undefined
|
|
: printTypeTitles[uiState.printType]
|
|
}
|
|
value={state.project.title || ""}
|
|
onInput={(evt) =>
|
|
setState("project", "title", evt.currentTarget.value)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<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
|
|
title={
|
|
'Agile Positionen werden mit Story Points "geschätzt". Was ist der kleinst mögliche Aufwand einer einzelnen Agilen Position?'
|
|
}
|
|
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="Wie hoch ist das Risiko, dass etwas schief geht? Dieser Wert dient zur Gewichtung des Mittelwerts zwischen den minimalen und maximalen Story Points pro Position. Bei einem Risiko von 0% werden nur die minimalen Story Points der Positionen berücksichtigt, bei einem Risiko von 100% nur die Maximalen. (0 - 100%)"
|
|
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>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem
|
|
item={1}
|
|
activeTitleColor="text-cyan-500"
|
|
label={
|
|
<>
|
|
<YouIcon />
|
|
<AccordionItemLabel>Deine Angaben</AccordionItemLabel>
|
|
<Show when={!yourDataForm.valid}>
|
|
<AccordionItemLabel>
|
|
<WarningIcon class="text-error" />
|
|
</AccordionItemLabel>
|
|
</Show>
|
|
</>
|
|
}
|
|
>
|
|
<AccordionContent>
|
|
<YourDataValidationContext>
|
|
<AccordionItemDivider>Bank Verbindung</AccordionItemDivider>
|
|
<AccordionItemGrid>
|
|
<div class="col-span-2">
|
|
<TextInput
|
|
required
|
|
label="Iban"
|
|
maxLength={50}
|
|
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>
|
|
Abweichende 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">
|
|
<NumberInput
|
|
required
|
|
label="MwST-Satz"
|
|
suffix="%"
|
|
value={new Big(localState.vatRate).mul(100).toNumber()}
|
|
onInput={(v) => {
|
|
v != null &&
|
|
setLocalState(
|
|
"vatRate",
|
|
new Big(v).div(100).toNumber()
|
|
);
|
|
}}
|
|
onBlur={resetInput(0)}
|
|
/>
|
|
</div>
|
|
</AccordionItemGrid>
|
|
<AccordionItemEnd />
|
|
</YourDataValidationContext>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem
|
|
item={2}
|
|
activeTitleColor="text-emerald-500"
|
|
label={
|
|
<>
|
|
<CustomerIcon />
|
|
<AccordionItemLabel>Kunde</AccordionItemLabel>
|
|
<Show when={!customerDataForm.valid}>
|
|
<AccordionItemLabel>
|
|
<WarningIcon class="text-error" />
|
|
</AccordionItemLabel>
|
|
</Show>
|
|
</>
|
|
}
|
|
>
|
|
<AccordionContent>
|
|
<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>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem
|
|
item={3}
|
|
activeTitleColor="text-orange-400"
|
|
label={
|
|
<>
|
|
<PositionsIcon />
|
|
<AccordionItemLabel>
|
|
Positionen <small>({state.positions.length})</small>
|
|
</AccordionItemLabel>
|
|
</>
|
|
}
|
|
>
|
|
<AccordionContent>
|
|
<PositionsSettings />
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem
|
|
activeTitleColor="text-blue-500"
|
|
label={
|
|
<>
|
|
<DesignIcon />
|
|
<AccordionItemLabel>Design</AccordionItemLabel>
|
|
</>
|
|
}
|
|
item={4}
|
|
>
|
|
<AccordionContent>
|
|
<AccordionItemDivider>Corporate Design</AccordionItemDivider>
|
|
<AccordionItemGrid>
|
|
<div class="col-span-2">
|
|
<Select
|
|
label="Adress-Layout"
|
|
labelMinWidth={fullWidthLabelWidth}
|
|
options={[
|
|
[ADDRESS_LAYOUT_LEFT, "Adresse links"],
|
|
[ADDRESS_LAYOUT_RIGHT, "Adresse rechts"],
|
|
]}
|
|
value={localState.addressLayout}
|
|
onChange={(v) => setLocalState("addressLayout", v)}
|
|
/>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<TextInput
|
|
ref={logoInputEl}
|
|
label="Logo"
|
|
labelMinWidth={fullWidthLabelWidth}
|
|
type="file"
|
|
class="file-input"
|
|
accept="image/png, image/jpeg, image/svg+xml"
|
|
suffix={
|
|
localState.logo ? (
|
|
<button
|
|
type="button"
|
|
class="btn btn-xs btn-error !rounded"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setLocalState("logo", undefined);
|
|
logoInputEl.value = "";
|
|
}}
|
|
>
|
|
<DeleteIcon />
|
|
</button>
|
|
) : undefined
|
|
}
|
|
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>
|
|
<AccordionItemDivider>Sonstiges</AccordionItemDivider>
|
|
<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>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</div>
|
|
<div class="overflow-hidden transition-opacity opacity-90 group-hover:opacity-100 group-focus-within:opacity-100 xxl:opacity-100">
|
|
<AccordionContent>
|
|
<div class="grid grid-cols-2 gap-2 mb-4">
|
|
<button
|
|
class="btn btn-sm btn-accent shadow-md 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;
|
|
}
|
|
|
|
const schema = z
|
|
.object({
|
|
state: storeSchema,
|
|
localState: localStoreSchema,
|
|
})
|
|
.collectErrors();
|
|
|
|
try {
|
|
let migrated = false;
|
|
const localStateVersion =
|
|
contentObject?.localState?.version;
|
|
const stateVersion = contentObject?.state?.version;
|
|
|
|
if (localStateVersion !== CURRENT_VERSION) {
|
|
migrateLocalState(contentObject?.localState);
|
|
migrated = true;
|
|
}
|
|
|
|
if (stateVersion !== CURRENT_VERSION) {
|
|
migrateState(contentObject?.state);
|
|
migrated = true;
|
|
}
|
|
|
|
if (migrated) {
|
|
migrateInfoLog(
|
|
localStateVersion < stateVersion
|
|
? localStateVersion
|
|
: stateVersion,
|
|
CURRENT_VERSION
|
|
);
|
|
}
|
|
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) {
|
|
setUiState("lastSaved", getUnixTime(new Date()));
|
|
await sleep(1100);
|
|
setLoadModal("open", false);
|
|
}
|
|
}}
|
|
>
|
|
<LoadIcon /> Laden
|
|
</button>
|
|
<button
|
|
class="btn btn-sm btn-accent shadow-md 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 shadow"
|
|
onChange={(evt) => {
|
|
setUiState("printType", evt.currentTarget.value as any);
|
|
evt.currentTarget.blur();
|
|
}}
|
|
>
|
|
<For
|
|
each={
|
|
[
|
|
PRINT_TYPE_LETTER,
|
|
PRINT_TYPE_OFFER,
|
|
PRINT_TYPE_CONFIRMATION,
|
|
PRINT_TYPE_INVOICE,
|
|
] as PrintType[]
|
|
}
|
|
>
|
|
{(type) => (
|
|
<option
|
|
value={type}
|
|
selected={type === uiState.printType}
|
|
>
|
|
{printTypeSelectTitles[type]}
|
|
</option>
|
|
)}
|
|
</For>
|
|
</select>
|
|
<button
|
|
class="btn btn-sm btn-primary flex-1 gap-2 shadow-md"
|
|
onClick={() => window.print()}
|
|
>
|
|
<PrinterIcon /> Drucken
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</AccordionContent>
|
|
</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>{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;
|