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.
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import {
|
|
Component,
|
|
createMemo,
|
|
JSX,
|
|
Show,
|
|
FlowComponent,
|
|
mergeProps,
|
|
ParentComponent,
|
|
createSignal,
|
|
onMount,
|
|
} from "solid-js";
|
|
import SwissInvoice, { InvoiceData } from "~/components/SwissInvoice";
|
|
import { Meta, Style, Title } from "solid-meta";
|
|
import Page from "~/components/Page";
|
|
import LufraiLogoWww from "~icons/custom/lufrai-logo-www";
|
|
import AppIcon from "~icons/custom/icon";
|
|
|
|
import { getLine1, getLine2, isStructuredAddress } from "~/components/Address";
|
|
import Positions, {
|
|
calculatePositionsPrice,
|
|
calculatePositionsTax,
|
|
} from "~/components/Positions";
|
|
import SettingsOverlay from "~/components/Settings/Overlay";
|
|
import {
|
|
ADDRESS_LAYOUT_LEFT,
|
|
ADDRESS_LAYOUT_RIGHT,
|
|
createLocalStore,
|
|
createStore,
|
|
createUiStore,
|
|
LocalStoreContext,
|
|
printTypeSelectTitles,
|
|
printTypeTitles,
|
|
PRINT_TYPE_CONFIRMATION,
|
|
PRINT_TYPE_INVOICE,
|
|
PRINT_TYPE_LETTER,
|
|
PRINT_TYPE_OFFER,
|
|
StoreContext,
|
|
UiStoreContext,
|
|
} from "~/stores";
|
|
import Big from "big.js";
|
|
import { getDisplayDateFromUnix, getHost, roundToStep } from "~/util";
|
|
import WelcomeModal, { description } from "~/components/WelcomeModal";
|
|
import Markdown from "~/components/Markdown";
|
|
// TODO: This should be a direct import of "shareon/css", but "shareon/css" doesnt work yet because solid-start / vite do not properly detect that as css
|
|
import "../shareon.css";
|
|
|
|
export default function Home() {
|
|
const store = createStore();
|
|
const [state, setState] = store;
|
|
const localStore = createLocalStore();
|
|
const [localState, setLocalState] = localStore;
|
|
const uiStore = createUiStore();
|
|
const [uiState, setUiState] = uiStore;
|
|
|
|
onMount(function () {
|
|
!import.meta.env.SSR && import("shareon").then((m) => m.init());
|
|
});
|
|
|
|
const invoiceData = createMemo((): InvoiceData => {
|
|
const amountBeforeTax = calculatePositionsPrice(state.positions, state);
|
|
const tax = calculatePositionsTax(amountBeforeTax, localState);
|
|
const totalAmount = new Big(amountBeforeTax).plus(tax).toNumber();
|
|
const totalAmountRounded = roundToStep(totalAmount, 0.05);
|
|
|
|
const creditor = localState.creditor;
|
|
|
|
return {
|
|
iban: localState.iban || "",
|
|
amount: totalAmountRounded,
|
|
amountBeforeTax,
|
|
tax,
|
|
currency: "CHF",
|
|
message: state.invoice.message,
|
|
reference: state.invoice.reference, //generate("12345 12345 12345 12345 1"),
|
|
creditor: {
|
|
type: isStructuredAddress(creditor) ? "S" : "K",
|
|
name: creditor.name,
|
|
city: creditor.city,
|
|
zip: creditor.zip || 8500,
|
|
country: "CH",
|
|
line1: creditor.line1,
|
|
line2: creditor.line2,
|
|
},
|
|
debtor: state.customer.debtorAddress.name
|
|
? {
|
|
type: isStructuredAddress(state.customer.debtorAddress) ? "S" : "K",
|
|
name: state.customer.debtorAddress.name,
|
|
city: state.customer.debtorAddress.city,
|
|
zip: state.customer.debtorAddress.zip,
|
|
country: "CH",
|
|
line1: state.customer.debtorAddress.line1,
|
|
line2: state.customer.debtorAddress.line2,
|
|
}
|
|
: undefined,
|
|
};
|
|
});
|
|
|
|
const InnerPadding: FlowComponent = (props) => (
|
|
<div
|
|
classList={{
|
|
"mx-16": true,
|
|
"print:mx-0": !state.fullWidthInvoice,
|
|
"print:mx-[11mm]": state.fullWidthInvoice,
|
|
}}
|
|
>
|
|
{props.children}
|
|
</div>
|
|
);
|
|
|
|
const PageHeader: Component = () => {
|
|
const RightItem: ParentComponent<
|
|
{ label: string; value?: any } & JSX.HTMLAttributes<HTMLDivElement>
|
|
> = (p) => {
|
|
const props = mergeProps({ show: true }, p);
|
|
|
|
return (
|
|
<Show when={!!props.value}>
|
|
<div class={props.class}>{props.label}</div>
|
|
<div class={"text-right " + props.class}>
|
|
{props.children || props.value}
|
|
</div>
|
|
</Show>
|
|
);
|
|
};
|
|
|
|
const address = createMemo(() => {
|
|
const value =
|
|
(localState.useCustomAddress && localState.customAddress
|
|
? localState.customAddress
|
|
: localState.creditor) || invoiceData().creditor;
|
|
|
|
return value;
|
|
});
|
|
|
|
const customerAddress = createMemo(() => {
|
|
return state.useCustomerAlternativeAddress
|
|
? state.customer.alternativeAddress
|
|
: state.customer.debtorAddress;
|
|
});
|
|
|
|
const titleMemo = createMemo(
|
|
() =>
|
|
(state.positions.length > 0 ? `(${state.positions.length}) ` : "") +
|
|
"Räppli - " +
|
|
(state.project.projectNumber.length > 0
|
|
? `${state.project.projectNumber} - `
|
|
: "") +
|
|
printTypeSelectTitles[uiState.printType]
|
|
);
|
|
|
|
const externalTitle = "Räppli - Web App für Schweizerische Rechnungen";
|
|
|
|
return (
|
|
<div class="break-words whitespace-normal">
|
|
<Title>{import.meta.env.SSR ? externalTitle : titleMemo()}</Title>
|
|
<h1 class="hidden">{externalTitle}</h1>
|
|
<Meta name="description" content={description} />
|
|
<Meta property="og:description" content={description} />
|
|
<Meta property="og:image" content={`${getHost()}/social.png`} />
|
|
<Meta property="og:title" content={externalTitle} />
|
|
<Meta property="og:url" content={getHost()} />
|
|
<Meta name="twitter:card" content="summary_large_image" />
|
|
<Meta name="twitter:site" content="@katy_wings" />
|
|
<div
|
|
classList={{
|
|
"flex justify-start items-center print:mb-7 print:h-28": true,
|
|
"h-20 mb-7": !!localState.logo,
|
|
"justify-start": localState.addressLayout == ADDRESS_LAYOUT_RIGHT,
|
|
"justify-end": localState.addressLayout == ADDRESS_LAYOUT_LEFT,
|
|
}}
|
|
>
|
|
<Show when={localState.logo}>
|
|
<img
|
|
classList={{
|
|
"max-h-full max-w-[50%] w-auto": true,
|
|
"h-full": localState.logo?.type.startsWith("image/svg"),
|
|
"h-auto": !localState.logo?.type.startsWith("image/svg"),
|
|
}}
|
|
width={localState.logo?.width}
|
|
height={localState.logo?.height}
|
|
src={localState.logo?.url}
|
|
/>
|
|
</Show>
|
|
</div>
|
|
<div class="print:min-h-[12rem] mb-10">
|
|
<div class="grid grid-cols-2 gap-x-[15%]">
|
|
<div
|
|
classList={{
|
|
"order-last": localState.addressLayout == ADDRESS_LAYOUT_RIGHT,
|
|
}}
|
|
>
|
|
<div class="text-sm mb-3">
|
|
{address().name
|
|
? [address().name, getLine1(address()), getLine2(address())]
|
|
.filter((x) => x != "")
|
|
.join(" · ")
|
|
: ""}
|
|
</div>
|
|
<div class="text-lg leading-snug">
|
|
<div>{customerAddress().name}</div>
|
|
<div>{getLine1(customerAddress())}</div>
|
|
<div>{getLine2(customerAddress())}</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="grid grid-cols-2 gap-x-4 text-sm">
|
|
<RightItem
|
|
class="font-bold"
|
|
label="Projekt Nr."
|
|
value={state.project.projectNumber}
|
|
/>
|
|
<RightItem
|
|
class="font-bold"
|
|
label="Bestellungs Nr."
|
|
value={state.project.orderNumber}
|
|
/>
|
|
<RightItem
|
|
value={getDisplayDateFromUnix(state.project.date)}
|
|
label="Datum"
|
|
/>
|
|
<RightItem
|
|
label="Lieferungs Nr."
|
|
value={state.project.deliveryNumber}
|
|
/>
|
|
<RightItem
|
|
label="Lieferdatum"
|
|
value={
|
|
state.project.deliveryDate != null &&
|
|
getDisplayDateFromUnix(state.project.deliveryDate)
|
|
}
|
|
/>
|
|
<RightItem
|
|
label="Kunden Nr."
|
|
value={state.customer.customerNumber}
|
|
/>
|
|
<RightItem
|
|
label="Ihre MwST-Nr."
|
|
value={state.customer.vatNumber}
|
|
/>
|
|
<Show
|
|
when={
|
|
localState.contact.name ||
|
|
localState.contact.phone ||
|
|
localState.contact.email
|
|
}
|
|
>
|
|
<hr class="col-span-2 my-2" />
|
|
<RightItem
|
|
value={localState.contact.name}
|
|
label="Ansprechpartner"
|
|
/>
|
|
<RightItem value={localState.contact.phone} label="Telefon" />
|
|
<RightItem
|
|
value={localState.contact.email}
|
|
label="E-Mail Adresse"
|
|
/>
|
|
</Show>
|
|
|
|
<Show when={localState.vatNumber}>
|
|
<div class="col-span-2 h-4"></div>
|
|
<RightItem label="MwST-Nr." value={localState.vatNumber} />
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PrintPreview: Component = () => {
|
|
const Title: FlowComponent = (props) => (
|
|
<div class="text-4xl mb-8 font-semibold">{props.children}</div>
|
|
);
|
|
|
|
const PositionsWithData = () => (
|
|
<Positions positions={state.positions} invoiceData={invoiceData()} />
|
|
);
|
|
|
|
const Preface = () => (
|
|
<Show when={state.project.preface}>
|
|
<Markdown class="mb-7" value={state.project.preface!} />
|
|
</Show>
|
|
);
|
|
|
|
const Conclusion = () => (
|
|
<Show when={state.project.conclusion}>
|
|
<Markdown class="mb-10" value={state.project.conclusion!} />
|
|
</Show>
|
|
);
|
|
|
|
const LufraiWatermark = () => (
|
|
<Show when={localState.showLufraiWatermark}>
|
|
<div class="text-xs mb-10 font-medium flex justify-center items-center">
|
|
<a
|
|
aria-disabled="true"
|
|
class="transition text-lufrai-primary-light hover:text-lufrai-primary leading-none fill-current hover:scale-110 flex items-center gap-2"
|
|
target="_blank"
|
|
rel="noopener"
|
|
href="https://lufrai.org"
|
|
>
|
|
Powered by <LufraiLogoWww class="w-auto h-7" />
|
|
</a>
|
|
</div>
|
|
</Show>
|
|
);
|
|
|
|
const pageTitle = createMemo(function () {
|
|
return state.project.title || printTypeTitles[uiState.printType];
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<Style type="text/css">{`
|
|
@page {
|
|
size: A4 portrait;
|
|
margin: 25mm ${state.fullWidthInvoice ? 0 : 11}mm 11mm ${
|
|
state.fullWidthInvoice ? 0 : 11
|
|
}mm;
|
|
}
|
|
|
|
.swissinvoice {
|
|
font-family: Liberation Sans, Helvetica, Arial, sans-serif;
|
|
}
|
|
|
|
@media print {
|
|
html {
|
|
font-size: 11px;
|
|
}
|
|
}
|
|
`}</Style>
|
|
<Show when={uiState.printType === PRINT_TYPE_LETTER}>
|
|
<Page>
|
|
<InnerPadding>
|
|
<PageHeader />
|
|
<Title>{pageTitle()}</Title>
|
|
<Preface />
|
|
<Conclusion />
|
|
<LufraiWatermark />
|
|
</InnerPadding>
|
|
</Page>
|
|
</Show>
|
|
<Show when={uiState.printType === PRINT_TYPE_OFFER}>
|
|
<Page>
|
|
<InnerPadding>
|
|
<PageHeader />
|
|
<Title>{pageTitle()}</Title>
|
|
<Preface />
|
|
<PositionsWithData />
|
|
<Conclusion />
|
|
<LufraiWatermark />
|
|
</InnerPadding>
|
|
</Page>
|
|
</Show>
|
|
<Show when={uiState.printType === PRINT_TYPE_CONFIRMATION}>
|
|
<Page>
|
|
<InnerPadding>
|
|
<PageHeader />
|
|
<Title>{pageTitle()}</Title>
|
|
<Preface />
|
|
<PositionsWithData />
|
|
<Conclusion />
|
|
<LufraiWatermark />
|
|
</InnerPadding>
|
|
</Page>
|
|
</Show>
|
|
<Show when={uiState.printType === PRINT_TYPE_INVOICE}>
|
|
<Page>
|
|
<InnerPadding>
|
|
<PageHeader />
|
|
<Title>{pageTitle()}</Title>
|
|
<Preface />
|
|
<PositionsWithData />
|
|
<Show when={localState.paymentTerms}>
|
|
<p class="mb-12 text-sm">
|
|
Zahlungsbedingungen: {localState.paymentTerms}
|
|
</p>
|
|
</Show>
|
|
<Conclusion />
|
|
<LufraiWatermark />
|
|
</InnerPadding>
|
|
<div
|
|
classList={{
|
|
"mx-16 print:mx-0": true,
|
|
"mx-0": state.fullWidthInvoice,
|
|
}}
|
|
>
|
|
<SwissInvoice value={invoiceData()} />
|
|
</div>
|
|
</Page>
|
|
</Show>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const [pulsingLogo, setPulsingLogo] = createSignal(false);
|
|
onMount(function () {
|
|
setPulsingLogo(true);
|
|
setTimeout(function () {
|
|
setPulsingLogo(false);
|
|
}, 4000);
|
|
});
|
|
|
|
return (
|
|
<UiStoreContext.Provider value={uiStore}>
|
|
<LocalStoreContext.Provider value={localStore}>
|
|
<StoreContext.Provider value={store}>
|
|
<div class="block xxl:flex xxl:h-screen print:block bg-slate-200 print:bg-transparent items-stretch">
|
|
<div
|
|
onClick={() => setLocalState("showWelcome", true)}
|
|
classList={{
|
|
"animate-pulse": pulsingLogo() && !localState.showWelcome,
|
|
"print:hidden hover:!opacity-100 fixed z-10 right-2 top-0 text-slate-600 m-4 transition-all duration-75 hover:text-swiss-red fill-current cursor-pointer hover:scale-110 text-6xl drop-shadow-md":
|
|
true,
|
|
}}
|
|
>
|
|
<AppIcon class="text-height" />
|
|
</div>
|
|
<SettingsOverlay />
|
|
<WelcomeModal />
|
|
<PrintPreview />
|
|
</div>
|
|
</StoreContext.Provider>
|
|
</LocalStoreContext.Provider>
|
|
</UiStoreContext.Provider>
|
|
);
|
|
}
|