@flowselections/offertes-installatie-techniek 1.0.3 → 1.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/dist-lib/components/offertes/AkkoordRoute.d.ts +2 -0
- package/dist-lib/components/offertes/AkkoordRoute.d.ts.map +1 -0
- package/dist-lib/components/offertes/AkkoordRoute.js +11 -0
- package/dist-lib/components/offertes/ArtikelKiezer.d.ts +16 -0
- package/dist-lib/components/offertes/ArtikelKiezer.d.ts.map +1 -0
- package/dist-lib/components/offertes/ArtikelKiezer.js +56 -0
- package/dist-lib/components/offertes/ExtraWerkMeldingenLijst.d.ts +4 -0
- package/dist-lib/components/offertes/ExtraWerkMeldingenLijst.d.ts.map +1 -0
- package/dist-lib/components/offertes/ExtraWerkMeldingenLijst.js +42 -0
- package/dist-lib/components/offertes/ExtraWerkOffertePaneel.d.ts +14 -0
- package/dist-lib/components/offertes/ExtraWerkOffertePaneel.d.ts.map +1 -0
- package/dist-lib/components/offertes/ExtraWerkOffertePaneel.js +91 -0
- package/dist-lib/components/offertes/MoederWerkbonPaneel.d.ts +5 -0
- package/dist-lib/components/offertes/MoederWerkbonPaneel.d.ts.map +1 -0
- package/dist-lib/components/offertes/MoederWerkbonPaneel.js +66 -0
- package/dist-lib/components/offertes/NieuweOfferteFormulier.d.ts +33 -0
- package/dist-lib/components/offertes/NieuweOfferteFormulier.d.ts.map +1 -0
- package/dist-lib/components/offertes/NieuweOfferteFormulier.js +523 -0
- package/dist-lib/components/offertes/OfferteA4.d.ts +28 -0
- package/dist-lib/components/offertes/OfferteA4.d.ts.map +1 -0
- package/dist-lib/components/offertes/OfferteA4.js +47 -0
- package/dist-lib/components/offertes/OfferteAkkoordPage.d.ts +4 -0
- package/dist-lib/components/offertes/OfferteAkkoordPage.d.ts.map +1 -0
- package/dist-lib/components/offertes/OfferteAkkoordPage.js +66 -0
- package/dist-lib/components/offertes/OfferteDetailPage.d.ts.map +1 -1
- package/dist-lib/components/offertes/OfferteDetailPage.js +247 -23
- package/dist-lib/components/offertes/OfferteRegelsEditor.js +1 -1
- package/dist-lib/components/offertes/OfferteStatusBadge.d.ts.map +1 -1
- package/dist-lib/components/offertes/OfferteStatusBadge.js +1 -0
- package/dist-lib/components/offertes/OffertesOverzichtPage.d.ts.map +1 -1
- package/dist-lib/components/offertes/OffertesOverzichtPage.js +25 -3
- package/dist-lib/components/offertes/VoorstelOffertePaneel.d.ts +14 -0
- package/dist-lib/components/offertes/VoorstelOffertePaneel.d.ts.map +1 -0
- package/dist-lib/components/offertes/VoorstelOffertePaneel.js +144 -0
- package/dist-lib/components/offertes/WerkbonAanmakenPaneel.d.ts +32 -0
- package/dist-lib/components/offertes/WerkbonAanmakenPaneel.d.ts.map +1 -0
- package/dist-lib/components/offertes/WerkbonAanmakenPaneel.js +26 -0
- package/dist-lib/components/offertes/WerkbonVoorstellenLijst.d.ts +4 -0
- package/dist-lib/components/offertes/WerkbonVoorstellenLijst.d.ts.map +1 -0
- package/dist-lib/components/offertes/WerkbonVoorstellenLijst.js +47 -0
- package/dist-lib/components/settings/OfferteEmailSettingsCard.d.ts +2 -0
- package/dist-lib/components/settings/OfferteEmailSettingsCard.d.ts.map +1 -0
- package/dist-lib/components/settings/OfferteEmailSettingsCard.js +38 -0
- package/dist-lib/components/settings/OfferteHuisstijlSettingsCard.d.ts +2 -0
- package/dist-lib/components/settings/OfferteHuisstijlSettingsCard.d.ts.map +1 -0
- package/dist-lib/components/settings/OfferteHuisstijlSettingsCard.js +66 -0
- package/dist-lib/components/settings/OfferteVoorwaardenSettingsCard.d.ts +2 -0
- package/dist-lib/components/settings/OfferteVoorwaardenSettingsCard.d.ts.map +1 -0
- package/dist-lib/components/settings/OfferteVoorwaardenSettingsCard.js +30 -0
- package/dist-lib/hooks/useExtraWerkMeldingen.d.ts +32 -0
- package/dist-lib/hooks/useExtraWerkMeldingen.d.ts.map +1 -0
- package/dist-lib/hooks/useExtraWerkMeldingen.js +103 -0
- package/dist-lib/hooks/useHuisstijl.d.ts +32 -0
- package/dist-lib/hooks/useHuisstijl.d.ts.map +1 -0
- package/dist-lib/hooks/useHuisstijl.js +60 -0
- package/dist-lib/hooks/useOfferte.d.ts +21 -0
- package/dist-lib/hooks/useOfferte.d.ts.map +1 -1
- package/dist-lib/hooks/useOfferte.js +105 -4
- package/dist-lib/hooks/useOffertes.d.ts.map +1 -1
- package/dist-lib/hooks/useOffertes.js +17 -7
- package/dist-lib/hooks/useWerkbonVoorstellen.d.ts +45 -0
- package/dist-lib/hooks/useWerkbonVoorstellen.d.ts.map +1 -0
- package/dist-lib/hooks/useWerkbonVoorstellen.js +74 -0
- package/dist-lib/index.d.ts +20 -2
- package/dist-lib/index.d.ts.map +1 -1
- package/dist-lib/index.js +27 -4
- package/dist-lib/integrations/supabase/auth-middleware.d.ts +4 -23403
- package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/auth-middleware.js +1 -1
- package/dist-lib/integrations/supabase/client.d.ts +2 -23403
- package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/client.server.d.ts +2 -23403
- package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/previewAuthStorage.d.ts +1 -1
- package/dist-lib/integrations/supabase/previewAuthStorage.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/previewAuthStorage.js +8 -1
- package/dist-lib/integrations/supabase/types.d.ts +23504 -11091
- package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/types.js +2 -0
- package/dist-lib/lib/artikelenZoek.d.ts +17 -0
- package/dist-lib/lib/artikelenZoek.d.ts.map +1 -0
- package/dist-lib/lib/artikelenZoek.js +37 -0
- package/dist-lib/lib/offerteAcceptatie.functions.d.ts +7 -0
- package/dist-lib/lib/offerteAcceptatie.functions.d.ts.map +1 -0
- package/dist-lib/lib/offerteAcceptatie.functions.js +28 -0
- package/dist-lib/lib/offerteAdres.d.ts +7 -0
- package/dist-lib/lib/offerteAdres.d.ts.map +1 -0
- package/dist-lib/lib/offerteAdres.js +19 -0
- package/dist-lib/lib/offerteAi.functions.d.ts +34 -0
- package/dist-lib/lib/offerteAi.functions.d.ts.map +1 -0
- package/dist-lib/lib/offerteAi.functions.js +252 -0
- package/dist-lib/lib/offerteMail.d.ts +27 -0
- package/dist-lib/lib/offerteMail.d.ts.map +1 -0
- package/dist-lib/lib/offerteMail.js +141 -0
- package/dist-lib/lib/offerteRegels.d.ts +9 -0
- package/dist-lib/lib/offerteRegels.d.ts.map +1 -0
- package/dist-lib/lib/offerteRegels.js +20 -0
- package/dist-lib/lib/offerteRegels.test.d.ts +2 -0
- package/dist-lib/lib/offerteRegels.test.d.ts.map +1 -0
- package/dist-lib/lib/offerteRegels.test.js +17 -0
- package/dist-lib/lib/offerteReistijd.functions.d.ts +22 -0
- package/dist-lib/lib/offerteReistijd.functions.d.ts.map +1 -0
- package/dist-lib/lib/offerteReistijd.functions.js +173 -0
- package/dist-lib/lib/offertes.d.ts +8 -1
- package/dist-lib/lib/offertes.d.ts.map +1 -1
- package/dist-lib/lib/offertes.js +6 -4
- package/package.json +97 -5
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
4
|
+
import { ChevronsUpDown, Clock3, Loader2, Minus, Plus, Send, Sparkles, Trash2, UserPlus } from "lucide-react";
|
|
5
|
+
import { supabase, toast, Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, } from "@flowselections/core";
|
|
6
|
+
import { formatEuro } from "../../lib/offertes";
|
|
7
|
+
import { genereerOfferteTeksten } from "../../lib/offerteAi.functions";
|
|
8
|
+
import { berekenOfferteReistijd } from "../../lib/offerteReistijd.functions";
|
|
9
|
+
import { adresLijktCompleet } from "../../lib/offerteAdres";
|
|
10
|
+
import { OfferteA4 } from "./OfferteA4";
|
|
11
|
+
import { a4NaarPdfBase64, haalOfferteVoorwaarden, verstuurOfferteEmail } from "../../lib/offerteMail";
|
|
12
|
+
import { useHuisstijl } from "../../hooks/useHuisstijl";
|
|
13
|
+
import { ArtikelKiezer, ArtikelFoto } from "./ArtikelKiezer";
|
|
14
|
+
const db = supabase;
|
|
15
|
+
const SOORTEN = ["Storing / reparatie", "Onderhoud", "Vervanging", "Nieuwe installatie", "Overig"];
|
|
16
|
+
const VAKGEBIEDEN = ["Verwarming", "Sanitair", "Ventilatie", "Elektra", "Koeling", "Overig"];
|
|
17
|
+
const LEGE_KLANT = {
|
|
18
|
+
name: "",
|
|
19
|
+
general_email: "",
|
|
20
|
+
general_phone: "",
|
|
21
|
+
billing_street: "",
|
|
22
|
+
billing_postcode: "",
|
|
23
|
+
billing_city: "",
|
|
24
|
+
kvk_number: "",
|
|
25
|
+
vat_number: "",
|
|
26
|
+
};
|
|
27
|
+
function adresVan(k) {
|
|
28
|
+
if (!k)
|
|
29
|
+
return "";
|
|
30
|
+
const pc = [k.billing_postcode, k.billing_city].filter(Boolean).join(" ");
|
|
31
|
+
return [k.billing_street, pc].filter(Boolean).join(", ");
|
|
32
|
+
}
|
|
33
|
+
function geldigEmail(e) {
|
|
34
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim());
|
|
35
|
+
}
|
|
36
|
+
function Sectie({ nr, titel, fout, children }) {
|
|
37
|
+
return (_jsxs("section", { className: `space-y-3 rounded-lg border p-4${fout ? " border-destructive" : ""}`, children: [_jsxs("h3", { className: `text-sm font-semibold${fout ? " text-destructive" : ""}`, children: [nr, ". ", titel] }), children] }));
|
|
38
|
+
}
|
|
39
|
+
function Veld({ label, verplicht, fout, children, }) {
|
|
40
|
+
return (_jsxs("div", { className: "space-y-1", children: [_jsxs(Label, { children: [label, verplicht ? " *" : ""] }), children, fout && _jsx("p", { className: "text-xs text-destructive", children: fout })] }));
|
|
41
|
+
}
|
|
42
|
+
export function NieuweOfferteFormulier({ open, onOpenChange: onOpenChangeProp, onCreated, inline, initial, bestaandeOfferteId, bronWerkbonNummer, meldingKort }) {
|
|
43
|
+
const onOpenChange = (o) => onOpenChangeProp?.(o);
|
|
44
|
+
const overslaanAutofill = useRef(false);
|
|
45
|
+
const genereer = useServerFn(genereerOfferteTeksten);
|
|
46
|
+
const berekenReistijd = useServerFn(berekenOfferteReistijd);
|
|
47
|
+
const [klanten, setKlanten] = useState([]);
|
|
48
|
+
const [pakketten, setPakketten] = useState([]);
|
|
49
|
+
const [klantId, setKlantId] = useState("");
|
|
50
|
+
const [klantOpen, setKlantOpen] = useState(false);
|
|
51
|
+
const [nieuweKlant, setNieuweKlant] = useState({ ...LEGE_KLANT });
|
|
52
|
+
const [klantFouten, setKlantFouten] = useState({});
|
|
53
|
+
const [klantSaving, setKlantSaving] = useState(false);
|
|
54
|
+
const [email, setEmail] = useState("");
|
|
55
|
+
const [werkadres, setWerkadres] = useState("");
|
|
56
|
+
const [kamer, setKamer] = useState("");
|
|
57
|
+
const [contactpersoon, setContactpersoon] = useState("");
|
|
58
|
+
const [contactTel, setContactTel] = useState("");
|
|
59
|
+
const [onderwerp, setOnderwerp] = useState("");
|
|
60
|
+
const [soort, setSoort] = useState("");
|
|
61
|
+
const [vakgebied, setVakgebied] = useState("");
|
|
62
|
+
const [toelichting, setToelichting] = useState("");
|
|
63
|
+
const [bijzTekst, setBijzTekst] = useState("");
|
|
64
|
+
const [gekozen, setGekozen] = useState([]);
|
|
65
|
+
const [materialen, setMaterialen] = useState([]);
|
|
66
|
+
const [automatischReisPakket, setAutomatischReisPakket] = useState(null);
|
|
67
|
+
const [reistijd, setReistijd] = useState(null);
|
|
68
|
+
const [reistijdBezig, setReistijdBezig] = useState(false);
|
|
69
|
+
const [reistijdPoging, setReistijdPoging] = useState(0);
|
|
70
|
+
const [pakketKeuze, setPakketKeuze] = useState("");
|
|
71
|
+
const [pakketOpen, setPakketOpen] = useState(false);
|
|
72
|
+
const [pakketZoek, setPakketZoek] = useState("");
|
|
73
|
+
const pakkettenGefilterd = useMemo(() => {
|
|
74
|
+
const q = pakketZoek.trim().toLowerCase();
|
|
75
|
+
if (!q)
|
|
76
|
+
return pakketten;
|
|
77
|
+
return pakketten.filter((p) => `${p.code ?? ""} ${p.name}`.toLowerCase().includes(q));
|
|
78
|
+
}, [pakketten, pakketZoek]);
|
|
79
|
+
const [btw, setBtw] = useState(21);
|
|
80
|
+
const [geldig, setGeldig] = useState(30);
|
|
81
|
+
const [klantTekst, setKlantTekst] = useState("");
|
|
82
|
+
const [monteurTekst, setMonteurTekst] = useState("");
|
|
83
|
+
const [voorwaarden, setVoorwaarden] = useState("");
|
|
84
|
+
const [aiBezig, setAiBezig] = useState(false);
|
|
85
|
+
const [saving, setSaving] = useState(false);
|
|
86
|
+
const [controleOpen, setControleOpen] = useState(false);
|
|
87
|
+
const [voorbeeld, setVoorbeeld] = useState(null);
|
|
88
|
+
const [mailAan, setMailAan] = useState("");
|
|
89
|
+
const [mailOnderwerp, setMailOnderwerp] = useState("");
|
|
90
|
+
const [mailBericht, setMailBericht] = useState("");
|
|
91
|
+
const [defNummer, setDefNummer] = useState(null);
|
|
92
|
+
const pdfRef = useRef(null);
|
|
93
|
+
const { huisstijl } = useHuisstijl();
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!open)
|
|
96
|
+
return;
|
|
97
|
+
(async () => {
|
|
98
|
+
const [k, p] = await Promise.all([
|
|
99
|
+
db.from("itk_customers")
|
|
100
|
+
.select("id, name, general_email, general_phone, billing_street, billing_postcode, billing_city")
|
|
101
|
+
.order("name")
|
|
102
|
+
.limit(1000),
|
|
103
|
+
db.rpc("quote_list_packages"),
|
|
104
|
+
]);
|
|
105
|
+
setKlanten(Array.isArray(k.data) ? k.data : []);
|
|
106
|
+
setPakketten((Array.isArray(p.data) ? p.data : []).filter((x) => x.active !== false));
|
|
107
|
+
haalOfferteVoorwaarden().then((v) => setVoorwaarden((huidig) => (huidig.trim() ? huidig : v))).catch(() => { });
|
|
108
|
+
})();
|
|
109
|
+
}, [open]);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!initial)
|
|
112
|
+
return;
|
|
113
|
+
overslaanAutofill.current = Boolean(initial.customerId);
|
|
114
|
+
setKlantId(initial.customerId ?? "");
|
|
115
|
+
setWerkadres(initial.werkadres ?? "");
|
|
116
|
+
setKamer(initial.kamer ?? "");
|
|
117
|
+
setOnderwerp(initial.onderwerp ?? "");
|
|
118
|
+
setToelichting(initial.toelichting ?? "");
|
|
119
|
+
setBijzTekst(initial.bijzonderheden ?? "");
|
|
120
|
+
setGekozen(Array.isArray(initial.packages) ? initial.packages : []);
|
|
121
|
+
setMaterialen(Array.isArray(initial.materials) ? initial.materials.map((m, i) => ({ key: `init-${i}-${Date.now()}`, description: m.description ?? "", unit: m.unit || "st", quantity: Number(m.quantity) > 0 ? Number(m.quantity) : 1, unit_price_cents: Number(m.unit_price_cents) || 0 })) : []);
|
|
122
|
+
setKlantTekst("");
|
|
123
|
+
setMonteurTekst("");
|
|
124
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
125
|
+
}, [bestaandeOfferteId, initial]);
|
|
126
|
+
const klant = klanten.find((k) => k.id === klantId) ?? null;
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (klant) {
|
|
129
|
+
setEmail(klant.general_email ?? "");
|
|
130
|
+
if (overslaanAutofill.current && werkadres.trim())
|
|
131
|
+
overslaanAutofill.current = false;
|
|
132
|
+
else
|
|
133
|
+
setWerkadres(adresVan(klant));
|
|
134
|
+
setContactTel(klant.general_phone ?? "");
|
|
135
|
+
}
|
|
136
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
137
|
+
}, [klantId, klanten.length]);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const bestemming = werkadres.trim();
|
|
140
|
+
if (!open || bestemming.length < 3 || pakketten.length === 0 || !adresLijktCompleet(bestemming)) {
|
|
141
|
+
setReistijd(open && bestemming.length >= 3 && pakketten.length > 0
|
|
142
|
+
? { status: "onbekend", minuten: null, pakket: null, melding: "Vul een volledig werkadres in (straat, huisnummer, plaats)." }
|
|
143
|
+
: null);
|
|
144
|
+
setGekozen((huidig) => automatischReisPakket ? huidig.filter((regel) => regel.package_id !== automatischReisPakket) : huidig);
|
|
145
|
+
setAutomatischReisPakket(null);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
let actief = true;
|
|
149
|
+
let timer;
|
|
150
|
+
let pogingen = 0;
|
|
151
|
+
const uitvoeren = async () => {
|
|
152
|
+
pogingen += 1;
|
|
153
|
+
setReistijdBezig(true);
|
|
154
|
+
try {
|
|
155
|
+
const { data: sessie } = await supabase.auth.getSession();
|
|
156
|
+
const token = sessie?.session?.access_token;
|
|
157
|
+
if (!token)
|
|
158
|
+
throw new Error("Niet ingelogd");
|
|
159
|
+
const resultaat = await berekenReistijd({ data: { accessToken: token, destination: bestemming } });
|
|
160
|
+
if (!actief)
|
|
161
|
+
return;
|
|
162
|
+
if (resultaat.status === "wacht" && pogingen >= 12) {
|
|
163
|
+
setReistijd({ status: "onbekend", minuten: null, pakket: null, melding: "Reistijd kon niet berekend worden." });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
setReistijd(resultaat);
|
|
167
|
+
setGekozen((huidig) => {
|
|
168
|
+
const zonderVorige = automatischReisPakket
|
|
169
|
+
? huidig.filter((regel) => regel.package_id !== automatischReisPakket)
|
|
170
|
+
: huidig;
|
|
171
|
+
if (resultaat.status !== "ok" || !resultaat.pakket || resultaat.pakket.aantal < 1)
|
|
172
|
+
return zonderVorige;
|
|
173
|
+
return [
|
|
174
|
+
...zonderVorige.filter((regel) => regel.package_id !== resultaat.pakket?.id),
|
|
175
|
+
{ package_id: resultaat.pakket.id, quantity: resultaat.pakket.aantal },
|
|
176
|
+
];
|
|
177
|
+
});
|
|
178
|
+
setAutomatischReisPakket(resultaat.status === "ok" ? resultaat.pakket?.id ?? null : null);
|
|
179
|
+
if (resultaat.status === "wacht" && actief)
|
|
180
|
+
timer = window.setTimeout(uitvoeren, 4000);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
if (actief) {
|
|
184
|
+
setReistijd({ status: "onbekend", minuten: null, pakket: null, melding: "Reistijd berekenen is mislukt. Probeer het opnieuw." });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
if (actief)
|
|
189
|
+
setReistijdBezig(false);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
timer = window.setTimeout(uitvoeren, 1500);
|
|
193
|
+
return () => {
|
|
194
|
+
actief = false;
|
|
195
|
+
if (timer !== undefined)
|
|
196
|
+
window.clearTimeout(timer);
|
|
197
|
+
};
|
|
198
|
+
}, [open, werkadres, pakketten.length, berekenReistijd, reistijdPoging]);
|
|
199
|
+
const regels = useMemo(() => gekozen.map((g) => {
|
|
200
|
+
const p = pakketten.find((x) => x.id === g.package_id);
|
|
201
|
+
return { ...g, naam: p?.name ?? "Pakket", prijs: Number(p?.unit_price_cents ?? 0) };
|
|
202
|
+
}), [gekozen, pakketten]);
|
|
203
|
+
const subtotaal = regels.reduce((s, r) => s + Math.round(r.quantity * r.prijs), 0) +
|
|
204
|
+
materialen.reduce((s, m) => s + Math.round(m.quantity * m.unit_price_cents), 0);
|
|
205
|
+
const materialenPayload = () => materialen.map((m) => ({ description: m.description.trim(), unit: m.unit.trim() || "st", quantity: m.quantity, unit_price_cents: m.unit_price_cents, article_number: m.article_number ?? null }));
|
|
206
|
+
const materialenOngeldig = materialen.some((m) => !m.description.trim() || !(m.quantity > 0));
|
|
207
|
+
const voegPakketToe = (id) => {
|
|
208
|
+
if (!id)
|
|
209
|
+
return;
|
|
210
|
+
setGekozen((g) => (g.some((x) => x.package_id === id) ? g.map((x) => (x.package_id === id ? { ...x, quantity: x.quantity + 1 } : x)) : [...g, { package_id: id, quantity: 1 }]));
|
|
211
|
+
setPakketKeuze("");
|
|
212
|
+
};
|
|
213
|
+
const formulierData = () => ({
|
|
214
|
+
klant: klant?.name,
|
|
215
|
+
werkadres, kamer, contactpersoon_op_locatie: contactpersoon,
|
|
216
|
+
onderwerp, soort_werk: soort, vakgebied, toelichting_eigenaar: toelichting,
|
|
217
|
+
bijzonderheden: [bijzTekst].filter(Boolean),
|
|
218
|
+
pakketten: regels.map((r) => ({ naam: r.naam, aantal: r.quantity })),
|
|
219
|
+
});
|
|
220
|
+
const stelTekstenOp = async () => {
|
|
221
|
+
if (!toelichting.trim()) {
|
|
222
|
+
toast.error("Vul eerst een korte toelichting in");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
setAiBezig(true);
|
|
226
|
+
try {
|
|
227
|
+
const { data: s } = await supabase.auth.getSession();
|
|
228
|
+
const token = s?.session?.access_token;
|
|
229
|
+
if (!token)
|
|
230
|
+
throw new Error("Niet ingelogd");
|
|
231
|
+
const res = await genereer({ data: { accessToken: token, formulier: formulierData() } });
|
|
232
|
+
if (res.error || !res.teksten) {
|
|
233
|
+
toast.error(res.error ?? "AI kon geen teksten opstellen");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
setKlantTekst(res.teksten.klant_tekst);
|
|
237
|
+
setMonteurTekst(res.teksten.monteur_instructies);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
toast.error("Teksten opstellen mislukt");
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
setAiBezig(false);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
// Elke regel in de toelichting begint met een punt; dit normaliseert ook
|
|
247
|
+
// geplakte tekst met meerdere regels.
|
|
248
|
+
const normalizeBullets = (raw) => raw
|
|
249
|
+
.split("\n")
|
|
250
|
+
.map((line) => {
|
|
251
|
+
if (line.startsWith("•")) {
|
|
252
|
+
return "• " + line.slice(1).replace(/^\s+/, "");
|
|
253
|
+
}
|
|
254
|
+
const stripped = line.replace(/^\s+/, "");
|
|
255
|
+
return stripped ? "• " + stripped : "";
|
|
256
|
+
})
|
|
257
|
+
.join("\n");
|
|
258
|
+
const toelichtingKeyDown = (e) => {
|
|
259
|
+
const ta = e.currentTarget;
|
|
260
|
+
const s = ta.selectionStart;
|
|
261
|
+
const t = ta.selectionEnd;
|
|
262
|
+
if (e.key === "Enter") {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
setToelichting((v) => v.slice(0, s) + "\n• " + v.slice(t));
|
|
265
|
+
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 3; });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (e.key === "Backspace" && s === t) {
|
|
269
|
+
if (toelichting === "• " && s === 2) {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
setToelichting("");
|
|
272
|
+
}
|
|
273
|
+
else if (toelichting.slice(0, s).endsWith("\n• ")) {
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
const n = s - 3;
|
|
276
|
+
setToelichting((v) => v.slice(0, n) + v.slice(t));
|
|
277
|
+
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = n; });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const reset = () => {
|
|
282
|
+
setKlantId("");
|
|
283
|
+
setOnderwerp("");
|
|
284
|
+
setToelichting("");
|
|
285
|
+
setGekozen([]);
|
|
286
|
+
setMaterialen([]);
|
|
287
|
+
setKlantTekst("");
|
|
288
|
+
setMonteurTekst("");
|
|
289
|
+
setKamer("");
|
|
290
|
+
setWerkadres("");
|
|
291
|
+
setEmail("");
|
|
292
|
+
setBijzTekst("");
|
|
293
|
+
setSoort("");
|
|
294
|
+
setVakgebied("");
|
|
295
|
+
setContactpersoon("");
|
|
296
|
+
setContactTel("");
|
|
297
|
+
setAutomatischReisPakket(null);
|
|
298
|
+
setReistijd(null);
|
|
299
|
+
setReistijdBezig(false);
|
|
300
|
+
};
|
|
301
|
+
const nk = (veld) => ({
|
|
302
|
+
value: nieuweKlant[veld],
|
|
303
|
+
onChange: (e) => {
|
|
304
|
+
setNieuweKlant((s) => ({ ...s, [veld]: e.target.value }));
|
|
305
|
+
setKlantFouten((f) => ({ ...f, [veld]: "" }));
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
const openKlantDialoog = () => {
|
|
309
|
+
setNieuweKlant({ ...LEGE_KLANT });
|
|
310
|
+
setKlantFouten({});
|
|
311
|
+
setKlantOpen(true);
|
|
312
|
+
};
|
|
313
|
+
const slaKlantOp = async () => {
|
|
314
|
+
const fouten = {};
|
|
315
|
+
if (!nieuweKlant.name.trim())
|
|
316
|
+
fouten.name = "Naam is verplicht";
|
|
317
|
+
if (!nieuweKlant.general_email.trim())
|
|
318
|
+
fouten.general_email = "E-mailadres is verplicht";
|
|
319
|
+
else if (!geldigEmail(nieuweKlant.general_email))
|
|
320
|
+
fouten.general_email = "Vul een geldig e-mailadres in";
|
|
321
|
+
if (!nieuweKlant.general_phone.trim())
|
|
322
|
+
fouten.general_phone = "Telefoonnummer is verplicht";
|
|
323
|
+
if (!nieuweKlant.billing_street.trim())
|
|
324
|
+
fouten.billing_street = "Straat en huisnummer zijn verplicht";
|
|
325
|
+
if (!nieuweKlant.billing_postcode.trim())
|
|
326
|
+
fouten.billing_postcode = "Postcode is verplicht";
|
|
327
|
+
if (!nieuweKlant.billing_city.trim())
|
|
328
|
+
fouten.billing_city = "Plaats is verplicht";
|
|
329
|
+
setKlantFouten(fouten);
|
|
330
|
+
if (Object.keys(fouten).length > 0)
|
|
331
|
+
return;
|
|
332
|
+
setKlantSaving(true);
|
|
333
|
+
try {
|
|
334
|
+
const ins = Object.fromEntries(Object.entries(nieuweKlant).map(([k, v]) => [k, String(v).trim() || null]));
|
|
335
|
+
const { data, error } = await db.from("itk_customers").insert(ins).select("id, name, general_email, general_phone, billing_street, billing_postcode, billing_city").single();
|
|
336
|
+
if (error || !data)
|
|
337
|
+
throw error ?? new Error("klant");
|
|
338
|
+
const nieuw = data;
|
|
339
|
+
setKlanten((ks) => [...ks, nieuw].sort((a, b) => a.name.localeCompare(b.name)));
|
|
340
|
+
setKlantId(nieuw.id);
|
|
341
|
+
setEmail(nieuw.general_email ?? "");
|
|
342
|
+
setWerkadres(adresVan(nieuw));
|
|
343
|
+
setContactTel(nieuw.general_phone ?? "");
|
|
344
|
+
setKlantOpen(false);
|
|
345
|
+
toast.success(`Klant ${nieuw.name} aangemaakt`);
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
toast.error(e?.message ? `Klant opslaan mislukt: ${e.message}` : "Klant opslaan mislukt");
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
setKlantSaving(false);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const tekstenCompleet = Boolean(klantTekst.trim() && monteurTekst.trim());
|
|
355
|
+
const a4Waarden = () => ({
|
|
356
|
+
klantNaam: klant?.name ?? "",
|
|
357
|
+
werkadres: [werkadres.trim(), kamer.trim()].filter(Boolean).join(" · "),
|
|
358
|
+
onderwerp: onderwerp.trim(),
|
|
359
|
+
aanleiding: "",
|
|
360
|
+
omschrijving: klantTekst.trim(),
|
|
361
|
+
aandachtspunten: aandachtspuntenTekst(),
|
|
362
|
+
voorwaarden: voorwaarden.trim(),
|
|
363
|
+
geldigTot: new Date(Date.now() + geldig * 86400000).toISOString().slice(0, 10),
|
|
364
|
+
vatRate: btw,
|
|
365
|
+
regels: [
|
|
366
|
+
...regels.map((r) => ({ description: r.naam, unit: "st", quantity: r.quantity, unit_price_cents: r.prijs })),
|
|
367
|
+
...materialenPayload(),
|
|
368
|
+
],
|
|
369
|
+
});
|
|
370
|
+
const aandachtspuntenTekst = () => [
|
|
371
|
+
bijzTekst.trim(),
|
|
372
|
+
contactpersoon.trim() ? `Contactpersoon op locatie: ${contactpersoon.trim()}${contactTel.trim() ? ` (${contactTel.trim()})` : ""}` : "",
|
|
373
|
+
].filter(Boolean).join("\n");
|
|
374
|
+
const openControle = () => {
|
|
375
|
+
if (!klantTekst.trim() || !monteurTekst.trim())
|
|
376
|
+
return toast.error("Stel eerst de teksten op met Teksten opstellen");
|
|
377
|
+
if (!klantId || !klant)
|
|
378
|
+
return toast.error("Kies een klant");
|
|
379
|
+
if (!onderwerp.trim())
|
|
380
|
+
return toast.error("Vul het onderwerp in");
|
|
381
|
+
if (reistijdBezig || reistijd?.status === "wacht")
|
|
382
|
+
return toast.error("Wacht tot de exacte reistijd is berekend");
|
|
383
|
+
if (!reistijd || reistijd.status !== "ok")
|
|
384
|
+
return toast.error(reistijd?.melding ?? "Vul een volledig werkadres in om de reistijd te berekenen");
|
|
385
|
+
if (gekozen.length === 0)
|
|
386
|
+
return toast.error("Kies minimaal één pakket");
|
|
387
|
+
if (materialenOngeldig)
|
|
388
|
+
return toast.error("Vul bij elk materiaal een omschrijving en aantal in");
|
|
389
|
+
setVoorbeeld(a4Waarden());
|
|
390
|
+
setMailAan(email.trim() || klant.general_email || "");
|
|
391
|
+
setMailOnderwerp(`Offerte – ${onderwerp.trim()}`);
|
|
392
|
+
if (bronWerkbonNummer) {
|
|
393
|
+
const kort = (meldingKort ?? "").trim().replace(/\s+/g, " ");
|
|
394
|
+
setMailBericht(`Beste ${klant.name || "klant"},\n\n` +
|
|
395
|
+
`Tijdens de werkzaamheden van werkbon ${bronWerkbonNummer} is gebleken dat er nog aanvullend werk nodig is${kort ? `: ${kort}` : ""}. ` +
|
|
396
|
+
`Omdat dit buiten het afgesproken mandaat valt, hebben wij hiervoor een aanvullende offerte opgesteld. Deze vindt u in de bijlage.\n\n` +
|
|
397
|
+
`Wij horen graag of u akkoord gaat met deze offerte.\n\n\n\n` +
|
|
398
|
+
`Met vriendelijke groet,\n\n${huisstijl?.company_name ?? ""}`);
|
|
399
|
+
setControleOpen(true);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
setMailBericht(`Beste ${klant.name || "klant"},\n\n` +
|
|
403
|
+
`Voor ${onderwerp.trim() || "de werkzaamheden"} sturen wij u onderstaande offerte. In de offerte leest u precies welke werkzaamheden moeten worden uitgevoerd en wat deze kosten. De offerte vindt u als bijlage bij deze e-mail.\n\n` +
|
|
404
|
+
`Wij horen graag of u akkoord gaat met deze offerte.\n\n\n\n` +
|
|
405
|
+
`Met vriendelijke groet,\n\n${huisstijl?.company_name ?? ""}`);
|
|
406
|
+
setControleOpen(true);
|
|
407
|
+
};
|
|
408
|
+
const slaOp = async (payload) => {
|
|
409
|
+
if (bestaandeOfferteId) {
|
|
410
|
+
const { error } = await db.rpc("quote_fill_from_form", { _offerte_id: bestaandeOfferteId, _payload: payload });
|
|
411
|
+
if (error)
|
|
412
|
+
throw error;
|
|
413
|
+
return bestaandeOfferteId;
|
|
414
|
+
}
|
|
415
|
+
const { data, error } = await db.rpc("quote_create_from_scratch", { _payload: payload });
|
|
416
|
+
if (error || !data?.offerte_id)
|
|
417
|
+
throw error ?? new Error("Offerte opslaan mislukt");
|
|
418
|
+
return data.offerte_id;
|
|
419
|
+
};
|
|
420
|
+
const opslaanAlsConcept = async () => {
|
|
421
|
+
if (!klant)
|
|
422
|
+
return toast.error("Kies een klant");
|
|
423
|
+
if (reistijdBezig || reistijd?.status === "wacht")
|
|
424
|
+
return toast.error("Wacht tot de exacte reistijd is berekend");
|
|
425
|
+
if (!reistijd || reistijd.status !== "ok")
|
|
426
|
+
return toast.error(reistijd?.melding ?? "Vul een volledig werkadres in om de reistijd te berekenen");
|
|
427
|
+
if (gekozen.length === 0)
|
|
428
|
+
return toast.error("Kies minimaal één pakket");
|
|
429
|
+
if (materialenOngeldig)
|
|
430
|
+
return toast.error("Vul bij elk materiaal een omschrijving en aantal in");
|
|
431
|
+
setSaving(true);
|
|
432
|
+
try {
|
|
433
|
+
const id = await slaOp({
|
|
434
|
+
customer_id: klant.id, onderwerp: onderwerp.trim(), klant_tekst: klantTekst.trim(),
|
|
435
|
+
monteur_instructies: monteurTekst.trim(), aandachtspunten: aandachtspuntenTekst(),
|
|
436
|
+
work_address: werkadres.trim(), room_number: kamer.trim(), vat_rate: btw, geldig_dagen: geldig,
|
|
437
|
+
notities: [soort, vakgebied].filter(Boolean).join(" · "), packages: gekozen, materials: materialenPayload(),
|
|
438
|
+
});
|
|
439
|
+
toast.success("Offerte als concept opgeslagen");
|
|
440
|
+
onCreated(id);
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
toast.error(`Opslaan mislukt: ${e?.message ?? ""}`);
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
setSaving(false);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
const bevestigVersturen = async () => {
|
|
450
|
+
if (!klant || !voorbeeld)
|
|
451
|
+
return;
|
|
452
|
+
const aan = mailAan.trim();
|
|
453
|
+
if (!geldigEmail(aan))
|
|
454
|
+
return toast.error("Vul een geldig e-mailadres in");
|
|
455
|
+
setSaving(true);
|
|
456
|
+
let offerteId = null;
|
|
457
|
+
try {
|
|
458
|
+
if (aan !== (klant.general_email ?? "")) {
|
|
459
|
+
await db.from("itk_customers").update({ general_email: aan }).eq("id", klant.id);
|
|
460
|
+
}
|
|
461
|
+
const payload = {
|
|
462
|
+
customer_id: klant.id,
|
|
463
|
+
onderwerp: onderwerp.trim(),
|
|
464
|
+
klant_tekst: voorbeeld.omschrijving,
|
|
465
|
+
monteur_instructies: monteurTekst.trim(),
|
|
466
|
+
aandachtspunten: aandachtspuntenTekst(),
|
|
467
|
+
voorwaarden: voorwaarden.trim(),
|
|
468
|
+
work_address: werkadres.trim(),
|
|
469
|
+
room_number: kamer.trim(),
|
|
470
|
+
vat_rate: btw,
|
|
471
|
+
geldig_dagen: geldig,
|
|
472
|
+
notities: [soort, vakgebied].filter(Boolean).join(" · "),
|
|
473
|
+
packages: gekozen, materials: materialenPayload(),
|
|
474
|
+
};
|
|
475
|
+
offerteId = await slaOp(payload);
|
|
476
|
+
const { data: nr, error: nrErr } = await db.rpc("quote_assign_number", { _offerte_id: offerteId });
|
|
477
|
+
if (nrErr || typeof nr !== "string")
|
|
478
|
+
throw new Error(nrErr?.message ?? "Offertenummer toekennen mislukt");
|
|
479
|
+
setDefNummer(nr);
|
|
480
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
481
|
+
const paginas = Array.from(pdfRef.current?.querySelectorAll("[data-offerte-pagina]") ?? []);
|
|
482
|
+
const pdfBase64 = await a4NaarPdfBase64(paginas);
|
|
483
|
+
const subject = mailOnderwerp.startsWith("Offerte") && !mailOnderwerp.includes(nr)
|
|
484
|
+
? mailOnderwerp.replace(/^Offerte/, `Offerte ${nr}`)
|
|
485
|
+
: mailOnderwerp;
|
|
486
|
+
await verstuurOfferteEmail({ offerteId, to: aan, subject, message: mailBericht, pdfBase64, filename: `Offerte-${nr}.pdf` });
|
|
487
|
+
toast.success(`Offerte ${nr} verzonden naar ${aan}. De werkbon wacht op goedkeuring.`);
|
|
488
|
+
setControleOpen(false);
|
|
489
|
+
onOpenChange(false);
|
|
490
|
+
if (!inline)
|
|
491
|
+
reset();
|
|
492
|
+
onCreated(offerteId);
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
const msg = e?.message ?? "Verzenden mislukt";
|
|
496
|
+
if (offerteId) {
|
|
497
|
+
toast.error(`Offerte als concept bewaard, maar niet verzonden: ${msg}`);
|
|
498
|
+
setControleOpen(false);
|
|
499
|
+
onOpenChange(false);
|
|
500
|
+
reset();
|
|
501
|
+
onCreated(offerteId);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
toast.error(`Verzenden mislukt: ${msg}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
setSaving(false);
|
|
509
|
+
setDefNummer(null);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
const velden = (_jsxs("div", { className: "space-y-4", children: [_jsxs(Sectie, { nr: 1, titel: "Klant", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Select, { value: klantId, onValueChange: setKlantId, children: [_jsx(SelectTrigger, { className: "flex-1", children: _jsx(SelectValue, { placeholder: "Kies een klant" }) }), _jsx(SelectContent, { className: "max-h-72", children: klanten.map((k) => _jsx(SelectItem, { value: k.id, children: k.name }, k.id)) })] }), _jsxs(Button, { type: "button", variant: "outline", onClick: openKlantDialoog, children: [_jsx(UserPlus, { className: "mr-1 h-4 w-4" }), "Nieuwe klant aanmaken"] })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "E-mailadres voor de offerte" }), _jsx(Input, { type: "email", value: email, onChange: (e) => setEmail(e.target.value), placeholder: "klant@voorbeeld.nl" })] })] }), _jsx(Sectie, { nr: 2, titel: "Locatie", children: _jsxs("div", { className: "grid gap-3 sm:grid-cols-2", children: [_jsxs("div", { className: "space-y-1 sm:col-span-2", children: [_jsx(Label, { children: "Werkadres" }), _jsx(Input, { value: werkadres, onChange: (e) => setWerkadres(e.target.value) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Kamer / ruimte" }), _jsx(Input, { value: kamer, onChange: (e) => setKamer(e.target.value) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Contactpersoon op locatie" }), _jsx(Input, { value: contactpersoon, onChange: (e) => setContactpersoon(e.target.value) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Telefoon contactpersoon" }), _jsx(Input, { value: contactTel, onChange: (e) => setContactTel(e.target.value) })] })] }) }), _jsxs(Sectie, { nr: 3, titel: "Het werk", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Onderwerp / klus *" }), _jsx(Input, { value: onderwerp, onChange: (e) => setOnderwerp(e.target.value), placeholder: "Bijv. vervangen cv-ketel" })] }), _jsxs("div", { className: "grid gap-3 sm:grid-cols-2", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Soort werk" }), _jsxs(Select, { value: soort, onValueChange: setSoort, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, { placeholder: "Kies" }) }), _jsx(SelectContent, { children: SOORTEN.map((s) => _jsx(SelectItem, { value: s, children: s }, s)) })] })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Vakgebied" }), _jsxs(Select, { value: vakgebied, onValueChange: setVakgebied, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, { placeholder: "Kies" }) }), _jsx(SelectContent, { children: VAKGEBIEDEN.map((s) => _jsx(SelectItem, { value: s, children: s }, s)) })] })] })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Korte toelichting" }), _jsx(Textarea, { rows: 3, value: toelichting, onChange: (e) => setToelichting(normalizeBullets(e.target.value)), onKeyDown: toelichtingKeyDown, onFocus: () => setToelichting((v) => (v ? v : "• ")), placeholder: "Geef de punten die aangepakt gaan worden" })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Bijzonderheden" }), _jsx(Input, { value: bijzTekst, onChange: (e) => setBijzTekst(e.target.value), placeholder: "Overige bijzonderheden" })] })] }), _jsxs(Sectie, { nr: 4, titel: "Prijs (pakketten en materialen)", children: [_jsxs("div", { className: `flex items-start gap-2 rounded-md border p-3 text-sm ${reistijd && reistijd.status !== "ok" ? "border-destructive text-destructive" : "bg-muted/40"}`, children: [reistijdBezig || reistijd?.status === "wacht" ? _jsx(Loader2, { className: "mt-0.5 h-4 w-4 shrink-0 animate-spin" }) : _jsx(Clock3, { className: "mt-0.5 h-4 w-4 shrink-0" }), _jsxs("div", { children: [_jsx("p", { className: "font-medium", children: "Reistijd vanaf de zaak" }), _jsx("p", { className: reistijd && reistijd.status !== "ok" ? "" : "text-muted-foreground", children: reistijdBezig ? "Reistijd wordt exact berekend…" : reistijd?.pakket
|
|
513
|
+
? `${reistijd.enkeleRitMinuten ?? reistijd.minuten} min heen + ${reistijd.enkeleRitMinuten ?? reistijd.minuten} min terug = ${reistijd.minuten} min · ${reistijd.pakket.aantal}× ${reistijd.pakket.naam} automatisch toegevoegd`
|
|
514
|
+
: reistijd?.melding ?? "Vul een werkadres in om de reistijd te berekenen." }), !reistijdBezig && (reistijd?.status === "kaarten_fout" || (reistijd?.status === "onbekend" && !reistijd.melding.startsWith("Vul "))) && (_jsx(Button, { type: "button", variant: "outline", size: "sm", className: "mt-2", onClick: () => setReistijdPoging((n) => n + 1), children: "Opnieuw proberen" }))] })] }), _jsxs("div", { className: "grid gap-2", children: [_jsxs(Popover, { open: pakketOpen, onOpenChange: setPakketOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", className: "w-full justify-between font-normal", children: ["Pakket toevoegen", _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsxs(PopoverContent, { className: "w-[var(--radix-popover-trigger-width)] p-0", align: "start", children: [_jsx("div", { className: "border-b p-2", children: _jsx(Input, { autoFocus: true, value: pakketZoek, onChange: (e) => setPakketZoek(e.target.value), placeholder: "Zoek een pakket\u2026" }) }), _jsxs("div", { className: "max-h-72 overflow-y-auto p-1", children: [pakkettenGefilterd.length === 0 && (_jsx("p", { className: "py-6 text-center text-sm text-muted-foreground", children: "Geen pakket gevonden." })), pakkettenGefilterd.filter((p) => !(Number(p.travel_minutes) > 0 && Number(p.labor_minutes ?? 0) === 0)).map((p) => (_jsxs("button", { type: "button", className: "relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground", onClick: () => {
|
|
515
|
+
voegPakketToe(p.id);
|
|
516
|
+
setPakketOpen(false);
|
|
517
|
+
setPakketZoek("");
|
|
518
|
+
}, children: [p.code ? `${p.code} · ` : "", p.name, " \u2014 ", formatEuro(Number(p.unit_price_cents ?? 0))] }, p.id)))] })] })] }), _jsx(ArtikelKiezer, { onKies: (a) => setMaterialen((m) => [...m, { key: crypto.randomUUID(), description: a.description, unit: a.unit, quantity: 1, unit_price_cents: a.unit_price_cents, article_number: a.article_number, foto_url: a.foto_url }]) })] }), materialen.length > 0 && (_jsx("div", { className: "divide-y rounded-md border", children: materialen.map((m) => {
|
|
519
|
+
const upd = (patch) => setMaterialen((lijst) => lijst.map((x) => (x.key === m.key ? { ...x, ...patch } : x)));
|
|
520
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-2 p-2 text-sm", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: "materiaal" }), _jsx(Input, { className: "min-w-40 flex-1", placeholder: "Omschrijving", value: m.description, onChange: (e) => upd({ description: e.target.value }) }), _jsx(Input, { className: "w-20 text-right", type: "number", step: "1", min: 0, value: m.quantity, onChange: (e) => upd({ quantity: Number(e.target.value) || 0 }) }), _jsx(Input, { className: "w-16", placeholder: "st", value: m.unit, onChange: (e) => upd({ unit: e.target.value }) }), _jsx(Input, { className: "w-24 text-right", type: "number", step: "0.01", min: 0, placeholder: "Prijs invullen", value: m.unit_price_cents ? m.unit_price_cents / 100 : "", onChange: (e) => upd({ unit_price_cents: Math.round((Number(e.target.value) || 0) * 100) }) }), _jsx("span", { className: "w-24 text-right", children: formatEuro(Math.round(m.quantity * m.unit_price_cents)) }), _jsx(ArtikelFoto, { url: m.foto_url, className: "size-8" }), _jsx(Button, { size: "icon", variant: "ghost", onClick: () => setMaterialen((lijst) => lijst.filter((x) => x.key !== m.key)), children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, m.key));
|
|
521
|
+
}) })), (regels.length > 0 || materialen.length > 0) && (_jsxs("div", { className: "divide-y rounded-md border", children: [regels.map((r) => (_jsxs("div", { className: "flex items-center gap-2 p-2 text-sm", children: [_jsxs("span", { className: "flex-1", children: [r.naam, r.package_id === automatischReisPakket && _jsx("span", { className: "ml-2 text-xs text-muted-foreground", children: "automatisch" })] }), _jsx(Button, { size: "icon", variant: "ghost", disabled: r.package_id === automatischReisPakket, onClick: () => setGekozen((g) => g.map((x) => (x.package_id === r.package_id ? { ...x, quantity: Math.max(1, x.quantity - 1) } : x))), children: _jsx(Minus, { className: "h-4 w-4" }) }), _jsx("span", { className: "w-6 text-center", children: r.quantity }), _jsx(Button, { size: "icon", variant: "ghost", disabled: r.package_id === automatischReisPakket, onClick: () => setGekozen((g) => g.map((x) => (x.package_id === r.package_id ? { ...x, quantity: x.quantity + 1 } : x))), children: _jsx(Plus, { className: "h-4 w-4" }) }), _jsx("span", { className: "w-24 text-right", children: formatEuro(Math.round(r.quantity * r.prijs)) }), _jsx(Button, { size: "icon", variant: "ghost", disabled: r.package_id === automatischReisPakket, onClick: () => setGekozen((g) => g.filter((x) => x.package_id !== r.package_id)), children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, r.package_id))), _jsxs("div", { className: "flex justify-between p-2 text-sm font-medium", children: [_jsx("span", { children: "Subtotaal excl. btw" }), _jsx("span", { children: formatEuro(subtotaal) })] })] })), _jsxs("div", { className: "grid gap-3 sm:grid-cols-2", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Btw %" }), _jsx(Input, { type: "number", value: btw, onChange: (e) => setBtw(Number(e.target.value) || 0) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Geldig (dagen)" }), _jsx(Input, { type: "number", value: geldig, onChange: (e) => setGeldig(Math.max(1, Number(e.target.value) || 30)) })] })] }), _jsx("p", { className: "text-xs text-muted-foreground", children: "De duur van de werkbon komt uit de pakketten; het mandaat uit de pakketten en materialen samen." })] }), _jsxs(Sectie, { nr: 5, titel: "Teksten *", fout: !tekstenCompleet, children: [_jsx("p", { className: "text-xs text-muted-foreground", children: "Verplicht: klik op Teksten opstellen voordat je kunt verzenden." }), _jsxs(Button, { variant: "outline", onClick: stelTekstenOp, disabled: aiBezig, children: [aiBezig ? _jsx(Loader2, { className: "mr-1 h-4 w-4 animate-spin" }) : _jsx(Sparkles, { className: "mr-1 h-4 w-4" }), "Teksten opstellen"] }), (klantTekst || monteurTekst) && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Tekst voor de klant (in de offerte)" }), _jsx(Textarea, { rows: 5, value: klantTekst, onChange: (e) => setKlantTekst(e.target.value) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Instructies voor de monteur (in de werkbon)" }), _jsx(Textarea, { rows: 6, value: monteurTekst, onChange: (e) => setMonteurTekst(e.target.value) })] })] }))] })] }));
|
|
522
|
+
return (_jsxs(_Fragment, { children: [inline ? (_jsxs("div", { className: "space-y-4", children: [velden, _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2 border-t pt-4", children: [!tekstenCompleet && (_jsx("p", { className: "mr-auto text-xs text-destructive", children: "Stel eerst de teksten op met Teksten opstellen." })), _jsx(Button, { variant: "outline", onClick: opslaanAlsConcept, disabled: saving, children: "Opslaan als concept" }), _jsxs(Button, { onClick: openControle, disabled: saving || !tekstenCompleet, children: [_jsx(Send, { className: "mr-1 h-4 w-4" }), "Verzenden"] })] })] })) : (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[92vh] overflow-y-auto sm:max-w-3xl", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Nieuwe offerte" }) }), velden, _jsxs(DialogFooter, { className: "items-center", children: [!tekstenCompleet && (_jsx("p", { className: "text-xs text-destructive", children: "Stel eerst de teksten op met Teksten opstellen." })), _jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), children: "Annuleren" }), _jsxs(Button, { onClick: openControle, disabled: saving || !tekstenCompleet, children: [_jsx(Send, { className: "mr-1 h-4 w-4" }), "Verzenden"] })] })] }) })), _jsx(Dialog, { open: controleOpen, onOpenChange: (o) => !saving && setControleOpen(o), children: _jsxs(DialogContent, { className: "z-[80] max-h-[92vh] overflow-y-auto sm:max-w-[1200px]", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Controleer offerte en e-mail" }) }), _jsxs("div", { className: "grid gap-6 lg:grid-cols-[minmax(0,1fr)_380px]", children: [_jsx("div", { className: "max-h-[75vh] overflow-auto rounded-md border bg-muted/30 p-4", children: voorbeeld && (_jsx("div", { ref: pdfRef, children: _jsx(OfferteA4, { waarden: voorbeeld, offerteNummer: defNummer, werkbonNummer: bronWerkbonNummer ?? null }) })) }), _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Aan" }), _jsx(Input, { type: "email", value: mailAan, onChange: (e) => setMailAan(e.target.value) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Onderwerp" }), _jsx(Input, { value: mailOnderwerp, onChange: (e) => setMailOnderwerp(e.target.value) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Bericht" }), _jsx(Textarea, { rows: 16, value: mailBericht, onChange: (e) => setMailBericht(e.target.value) })] }), _jsx("p", { className: "text-xs text-muted-foreground", children: "De offerte gaat als PDF-bijlage mee. De werkbon wordt klaargezet en gaat pas naar Werkbonnen zodra de offerte is goedgekeurd." })] })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setControleOpen(false), disabled: saving, children: "Terug" }), _jsxs(Button, { onClick: bevestigVersturen, disabled: saving, children: [saving && _jsx(Loader2, { className: "mr-1 h-4 w-4 animate-spin" }), "Bevestig versturen"] })] })] }) }), _jsx(Dialog, { open: klantOpen, onOpenChange: setKlantOpen, children: _jsxs(DialogContent, { className: "z-[80] sm:max-w-lg", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Nieuwe klant aanmaken" }) }), _jsxs("div", { className: "grid gap-3 sm:grid-cols-2", children: [_jsx("div", { className: "sm:col-span-2", children: _jsx(Veld, { label: "Naam", verplicht: true, fout: klantFouten.name, children: _jsx(Input, { ...nk("name") }) }) }), _jsx(Veld, { label: "E-mailadres", verplicht: true, fout: klantFouten.general_email, children: _jsx(Input, { type: "email", ...nk("general_email") }) }), _jsx(Veld, { label: "Telefoon", verplicht: true, fout: klantFouten.general_phone, children: _jsx(Input, { ...nk("general_phone") }) }), _jsx("div", { className: "sm:col-span-2", children: _jsx(Veld, { label: "Straat + huisnummer", verplicht: true, fout: klantFouten.billing_street, children: _jsx(Input, { ...nk("billing_street") }) }) }), _jsx(Veld, { label: "Postcode", verplicht: true, fout: klantFouten.billing_postcode, children: _jsx(Input, { ...nk("billing_postcode") }) }), _jsx(Veld, { label: "Plaats", verplicht: true, fout: klantFouten.billing_city, children: _jsx(Input, { ...nk("billing_city") }) }), _jsx(Veld, { label: "KvK (optioneel)", children: _jsx(Input, { ...nk("kvk_number") }) }), _jsx(Veld, { label: "Btw-nummer (optioneel)", children: _jsx(Input, { ...nk("vat_number") }) })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setKlantOpen(false), children: "Annuleren" }), _jsxs(Button, { onClick: slaKlantOp, disabled: klantSaving, children: [klantSaving && _jsx(Loader2, { className: "mr-1 h-4 w-4 animate-spin" }), "Klant opslaan"] })] })] }) })] }));
|
|
523
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface OfferteA4Regel {
|
|
2
|
+
description: string;
|
|
3
|
+
unit: string;
|
|
4
|
+
quantity: number;
|
|
5
|
+
unit_price_cents: number;
|
|
6
|
+
}
|
|
7
|
+
export interface OfferteA4Waarden {
|
|
8
|
+
klantNaam: string;
|
|
9
|
+
werkadres: string;
|
|
10
|
+
onderwerp: string;
|
|
11
|
+
aanleiding: string;
|
|
12
|
+
omschrijving: string;
|
|
13
|
+
aandachtspunten: string;
|
|
14
|
+
voorwaarden: string;
|
|
15
|
+
geldigTot: string;
|
|
16
|
+
vatRate: number;
|
|
17
|
+
regels: OfferteA4Regel[];
|
|
18
|
+
}
|
|
19
|
+
interface Props {
|
|
20
|
+
waarden: OfferteA4Waarden;
|
|
21
|
+
offerteNummer: string | null;
|
|
22
|
+
werkbonNummer: string | null;
|
|
23
|
+
editable?: boolean;
|
|
24
|
+
onChange?: (w: OfferteA4Waarden) => void;
|
|
25
|
+
}
|
|
26
|
+
export declare const OfferteA4: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLDivElement>>;
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=OfferteA4.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OfferteA4.d.ts","sourceRoot":"","sources":["../../../src/components/offertes/OfferteA4.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,cAAc,EAAE,CAAC;CAC1B;AAED,UAAU,KAAK;IACb,OAAO,EAAE,gBAAgB,CAAC;IAC1B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,CAAC,EAAE,gBAAgB,KAAK,IAAI,CAAC;CAC1C;AAyED,eAAO,MAAM,SAAS,kGAqIpB,CAAC"}
|