@spaceinvoices/react-ui 0.4.6 → 0.4.7

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.
Files changed (55) hide show
  1. package/cli/dist/index.js +1 -1
  2. package/package.json +1 -1
  3. package/src/components/advance-invoices/create/create-advance-invoice-form.tsx +60 -44
  4. package/src/components/credit-notes/create/create-credit-note-form.tsx +52 -42
  5. package/src/components/dashboard/collection-rate-card/use-collection-rate.ts +48 -92
  6. package/src/components/dashboard/invoice-status-chart/use-invoice-status.ts +48 -82
  7. package/src/components/dashboard/payment-methods-chart/use-payment-methods.ts +22 -31
  8. package/src/components/dashboard/payment-trend-chart/use-payment-trend.ts +33 -48
  9. package/src/components/dashboard/revenue-trend-chart/use-revenue-trend.ts +56 -76
  10. package/src/components/dashboard/shared/index.ts +1 -1
  11. package/src/components/dashboard/shared/use-revenue-data.ts +106 -182
  12. package/src/components/dashboard/shared/use-stats-counts.ts +18 -68
  13. package/src/components/dashboard/shared/use-stats-query.ts +35 -5
  14. package/src/components/dashboard/tax-collected-card/use-tax-collected.ts +57 -75
  15. package/src/components/dashboard/top-customers-chart/use-top-customers.ts +38 -49
  16. package/src/components/delivery-notes/create/create-delivery-note-form.tsx +3 -2
  17. package/src/components/documents/create/document-details-section.tsx +6 -4
  18. package/src/components/documents/create/document-recipient-section.tsx +30 -1
  19. package/src/components/documents/create/live-preview.tsx +15 -28
  20. package/src/components/documents/create/prepare-document-submission.ts +1 -0
  21. package/src/components/documents/create/use-document-customer-form.ts +4 -0
  22. package/src/components/documents/shared/document-preview-skeleton.tsx +63 -0
  23. package/src/components/documents/shared/index.ts +1 -0
  24. package/src/components/documents/view/document-actions-bar.tsx +29 -7
  25. package/src/components/entities/settings/tax-rules-settings-form.tsx +31 -13
  26. package/src/components/estimates/create/create-estimate-form.tsx +3 -2
  27. package/src/components/invoices/create/create-invoice-form.tsx +134 -62
  28. package/src/components/invoices/create/locales/de.ts +6 -0
  29. package/src/components/invoices/create/locales/es.ts +6 -0
  30. package/src/components/invoices/create/locales/fr.ts +6 -0
  31. package/src/components/invoices/create/locales/hr.ts +6 -0
  32. package/src/components/invoices/create/locales/it.ts +6 -0
  33. package/src/components/invoices/create/locales/nl.ts +6 -0
  34. package/src/components/invoices/create/locales/pl.ts +6 -0
  35. package/src/components/invoices/create/locales/pt.ts +6 -0
  36. package/src/components/invoices/create/locales/sl.ts +6 -0
  37. package/src/components/invoices/invoices.hooks.ts +1 -1
  38. package/src/components/ui/progress.tsx +27 -0
  39. package/src/generate-schemas.ts +15 -2
  40. package/src/generated/schemas/advanceinvoice.ts +2 -0
  41. package/src/generated/schemas/creditnote.ts +2 -0
  42. package/src/generated/schemas/customer.ts +2 -0
  43. package/src/generated/schemas/deliverynote.ts +2 -0
  44. package/src/generated/schemas/entity.ts +10 -0
  45. package/src/generated/schemas/estimate.ts +2 -0
  46. package/src/generated/schemas/finasettings.ts +4 -3
  47. package/src/generated/schemas/invoice.ts +2 -0
  48. package/src/generated/schemas/renderadvanceinvoicepreview_body.ts +16 -10
  49. package/src/generated/schemas/rendercreditnotepreview_body.ts +16 -10
  50. package/src/generated/schemas/renderdeliverynotepreview_body.ts +14 -7
  51. package/src/generated/schemas/renderestimatepreview_body.ts +14 -7
  52. package/src/generated/schemas/renderinvoicepreview_body.ts +16 -10
  53. package/src/generated/schemas/startpdfexport_body.ts +12 -17
  54. package/src/hooks/use-transaction-type-check.ts +152 -0
  55. package/src/hooks/use-vies-check.ts +7 -131
@@ -1,68 +1,57 @@
1
1
  /**
2
2
  * Top customers hook using the entity stats API.
3
3
  * Server-side aggregation by customer for accurate rankings.
4
+ * Sends 1 query in a batch request.
4
5
  */
5
6
  import type { StatsQueryDataItem } from "@spaceinvoices/js-sdk";
6
- import { useQuery } from "@tanstack/react-query";
7
- import { useSDK } from "@/ui/providers/sdk-provider";
8
- import { STATS_QUERY_CACHE_KEY } from "../shared/use-stats-query";
7
+ import { useStatsQuery } from "../shared/use-stats-query";
9
8
 
10
9
  export const TOP_CUSTOMERS_CACHE_KEY = "dashboard-top-customers";
11
10
 
12
11
  export type TopCustomersData = { name: string; revenue: number }[];
13
12
 
14
13
  export function useTopCustomersData(entityId: string | undefined, limit = 5) {
15
- const { sdk } = useSDK();
16
-
17
- const query = useQuery({
18
- queryKey: [STATS_QUERY_CACHE_KEY, entityId, "top-customers", limit],
19
- queryFn: async () => {
20
- if (!entityId || !sdk) throw new Error("Missing entity or SDK");
21
- return sdk.entityStats.queryEntityStats(
22
- {
23
- metrics: [
24
- { type: "sum", field: "total_with_tax_converted", alias: "revenue" },
25
- { type: "count", alias: "invoice_count" },
26
- ],
27
- table: "invoices",
28
- filters: { is_draft: false, voided_at: null },
29
- group_by: ["customer_name", "quote_currency"], // Include currency for display
30
- order_by: [{ field: "revenue", direction: "desc" }],
31
- limit: limit * 2, // Get more results to aggregate across currencies
32
- },
33
- { entity_id: entityId },
34
- );
14
+ const query = useStatsQuery(
15
+ entityId,
16
+ {
17
+ metrics: [
18
+ { type: "sum", field: "total_with_tax_converted", alias: "revenue" },
19
+ { type: "count", alias: "invoice_count" },
20
+ ],
21
+ table: "invoices",
22
+ filters: { is_draft: false, voided_at: null },
23
+ group_by: ["customer_name", "quote_currency"],
24
+ order_by: [{ field: "revenue", direction: "desc" }],
25
+ limit: limit * 2,
35
26
  },
36
- enabled: !!entityId && !!sdk,
37
- staleTime: 120_000,
38
- select: (response) => {
39
- const data = response.data || [];
40
-
41
- // Aggregate by customer name (in case of multiple quote_currency rows)
42
- const customerMap: Record<string, number> = {};
43
- let currency = "EUR";
44
-
45
- for (const row of data as StatsQueryDataItem[]) {
46
- const name = String(row.customer_name || "Unknown");
47
- customerMap[name] = (customerMap[name] || 0) + (Number(row.revenue) || 0);
48
- // Get currency from first row with data
49
- if (row.quote_currency && currency === "EUR") {
50
- currency = String(row.quote_currency);
27
+ {
28
+ select: (response) => {
29
+ const data = response.data || [];
30
+
31
+ // Aggregate by customer name (in case of multiple quote_currency rows)
32
+ const customerMap: Record<string, number> = {};
33
+ let currency = "EUR";
34
+
35
+ for (const row of data as StatsQueryDataItem[]) {
36
+ const name = String(row.customer_name || "Unknown");
37
+ customerMap[name] = (customerMap[name] || 0) + (Number(row.revenue) || 0);
38
+ if (row.quote_currency && currency === "EUR") {
39
+ currency = String(row.quote_currency);
40
+ }
51
41
  }
52
- }
53
42
 
54
- // Convert to array, sort by revenue, and take top 'limit'
55
- const customers = Object.entries(customerMap)
56
- .map(([name, revenue]) => ({ name, revenue }))
57
- .sort((a, b) => b.revenue - a.revenue)
58
- .slice(0, limit);
43
+ const customers = Object.entries(customerMap)
44
+ .map(([name, revenue]) => ({ name, revenue }))
45
+ .sort((a, b) => b.revenue - a.revenue)
46
+ .slice(0, limit);
59
47
 
60
- return {
61
- data: customers,
62
- currency, // Currency from document data
63
- };
48
+ return {
49
+ data: customers,
50
+ currency,
51
+ };
52
+ },
64
53
  },
65
- });
54
+ );
66
55
 
67
56
  return {
68
57
  data: query.data?.data || [],
@@ -10,7 +10,7 @@ import { Form } from "@/ui/components/ui/form";
10
10
  import { Label } from "@/ui/components/ui/label";
11
11
  import { createDeliveryNoteSchema } from "@/ui/generated/schemas";
12
12
  import { useNextDocumentNumber } from "@/ui/hooks/use-next-document-number";
13
- import { useViesCheck } from "@/ui/hooks/use-vies-check";
13
+ import { useTransactionTypeCheck } from "@/ui/hooks/use-transaction-type-check";
14
14
  import type { ComponentTranslationProps } from "@/ui/lib/translation";
15
15
  import { createTranslation } from "@/ui/lib/translation";
16
16
  import { useEntities } from "@/ui/providers/entities-context";
@@ -202,12 +202,13 @@ export default function CreateDeliveryNoteForm({
202
202
  transactionType,
203
203
  isFetching: isViesFetching,
204
204
  warning: viesWarning,
205
- } = useViesCheck({
205
+ } = useTransactionTypeCheck({
206
206
  issuerCountryCode: activeEntity?.country_code,
207
207
  isTaxSubject: activeEntity?.is_tax_subject ?? true,
208
208
  customerCountry: formValues.customer?.country,
209
209
  customerCountryCode: formValues.customer?.country_code,
210
210
  customerTaxNumber: formValues.customer?.tax_number,
211
+ customerIsEndConsumer: (formValues.customer as any)?.is_end_consumer,
211
212
  enabled: !!activeEntity,
212
213
  });
213
214
 
@@ -2,7 +2,7 @@
2
2
  * Shared document details section for invoices and estimates
3
3
  * Handles: number, date, and document-type-specific date field (date_due or date_valid_till)
4
4
  */
5
- import type { Entity, Estimate, Invoice, ViesCheckResponse } from "@spaceinvoices/js-sdk";
5
+ import type { Entity, Estimate, Invoice, TransactionTypeCheckResponse } from "@spaceinvoices/js-sdk";
6
6
  import { CalendarIcon, ChevronDown, Globe, Info, Loader2 } from "lucide-react";
7
7
  import { useRef, useState } from "react";
8
8
  import { Badge } from "@/ui/components/ui/badge";
@@ -596,20 +596,22 @@ export function DocumentNoteField({
596
596
  * Tax clause field component with smart code insertion button
597
597
  * Similar to DocumentNoteField, auto-populated from entity settings based on transaction type
598
598
  */
599
- type TransactionType = ViesCheckResponse["transaction_type"];
599
+ type TransactionType = TransactionTypeCheckResponse["transaction_type"];
600
600
 
601
601
  const TRANSACTION_TYPE_LABELS: Record<NonNullable<TransactionType>, string> = {
602
602
  domestic: "Domestic",
603
603
  intra_eu_b2b: "EU B2B",
604
604
  intra_eu_b2c: "EU B2C",
605
- export: "Export",
605
+ "3w_b2b": "3W B2B",
606
+ "3w_b2c": "3W B2C",
606
607
  };
607
608
 
608
609
  const TRANSACTION_TYPE_VARIANTS: Record<NonNullable<TransactionType>, "secondary" | "default" | "outline"> = {
609
610
  domestic: "secondary",
610
611
  intra_eu_b2b: "default",
611
612
  intra_eu_b2c: "outline",
612
- export: "outline",
613
+ "3w_b2b": "outline",
614
+ "3w_b2c": "outline",
613
615
  };
614
616
 
615
617
  export function DocumentTaxClauseField({
@@ -4,8 +4,10 @@
4
4
  */
5
5
  import { X } from "lucide-react";
6
6
  import { useEffect, useRef } from "react";
7
+ import { useController } from "react-hook-form";
7
8
  import { FormInput } from "@/ui/components/form";
8
9
  import { Button } from "@/ui/components/ui/button";
10
+ import { Checkbox } from "@/ui/components/ui/checkbox";
9
11
  import { Label } from "@/ui/components/ui/label";
10
12
  import { cn } from "@/ui/lib/utils";
11
13
  import { CustomerAutocomplete } from "../../customers/customer-autocomplete";
@@ -22,6 +24,8 @@ type DocumentRecipientSectionProps = {
22
24
  selectedCustomerId?: string;
23
25
  /** Initial customer name for display (used when duplicating documents) */
24
26
  initialCustomerName?: string;
27
+ /** Show end consumer (B2C) toggle next to tax number (Croatian entity + domestic transaction) */
28
+ showEndConsumerToggle?: boolean;
25
29
  t: (key: string) => string;
26
30
  };
27
31
 
@@ -34,10 +38,16 @@ export function DocumentRecipientSection({
34
38
  shouldFocusName,
35
39
  selectedCustomerId,
36
40
  initialCustomerName,
41
+ showEndConsumerToggle,
37
42
  t,
38
43
  }: DocumentRecipientSectionProps) {
39
44
  const nameInputRef = useRef<HTMLInputElement>(null);
40
45
 
46
+ const endConsumerController = useController({
47
+ control: control as any,
48
+ name: "customer.is_end_consumer" as any,
49
+ });
50
+
41
51
  useEffect(() => {
42
52
  if (showCustomerForm && shouldFocusName) {
43
53
  // Small delay to ensure the input is rendered
@@ -93,7 +103,26 @@ export function DocumentRecipientSection({
93
103
  <FormInput control={control} name="customer.country" placeholder={t("Country")} label="" />
94
104
  </div>
95
105
 
96
- <FormInput control={control} name="customer.tax_number" placeholder={t("Tax Number")} label="" />
106
+ <div className="flex items-center gap-3">
107
+ <div className="flex-1">
108
+ <FormInput control={control} name="customer.tax_number" placeholder={t("Tax Number")} label="" />
109
+ </div>
110
+ {showEndConsumerToggle && (
111
+ <div className="flex items-center gap-1.5 pt-0.5">
112
+ <Checkbox
113
+ id="is_end_consumer"
114
+ checked={endConsumerController.field.value === true}
115
+ onCheckedChange={(checked) => endConsumerController.field.onChange(checked === true)}
116
+ />
117
+ <Label
118
+ htmlFor="is_end_consumer"
119
+ className="cursor-pointer whitespace-nowrap font-normal text-muted-foreground text-sm"
120
+ >
121
+ {t("End consumer")}
122
+ </Label>
123
+ </div>
124
+ )}
125
+ </div>
97
126
  </>
98
127
  )}
99
128
  </div>
@@ -1,11 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import type { CreateInvoiceRequest } from "@spaceinvoices/js-sdk";
4
- import { Loader2 } from "lucide-react";
5
4
  import { useCallback, useEffect, useRef, useState } from "react";
6
5
  import { cn } from "@/ui/lib/utils";
7
6
  import { useEntities } from "@/ui/providers/entities-context";
8
7
  import { useSDK } from "@/ui/providers/sdk-provider";
8
+ import { DocumentPreviewSkeleton } from "../shared/document-preview-skeleton";
9
9
  import { ScaledDocumentPreview } from "../shared/scaled-document-preview";
10
10
  import { useA4Scaling } from "../shared/use-a4-scaling";
11
11
  import type { DocumentTypes } from "../types";
@@ -58,7 +58,7 @@ export function LiveInvoicePreview({
58
58
  locale: _locale,
59
59
  fixedScale,
60
60
  t: tProp,
61
- documentTypeLabel,
61
+ documentTypeLabel: _documentTypeLabel,
62
62
  documentType = "invoice",
63
63
  qrOverrides,
64
64
  }: LiveInvoicePreviewProps) {
@@ -235,18 +235,10 @@ export function LiveInvoicePreview({
235
235
  };
236
236
  }, []);
237
237
 
238
+ const showSkeleton = (!previewHtml && !error) || (isLoading && !previewHtml);
239
+
238
240
  return (
239
241
  <div ref={containerRef} className={cn("relative", className)}>
240
- {/* Loading overlay */}
241
- {isLoading && (
242
- <div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm">
243
- <div className="flex flex-col items-center gap-2">
244
- <Loader2 className="h-8 w-8 animate-spin text-primary" />
245
- <p className="text-muted-foreground text-sm">{t("Generating preview...")}</p>
246
- </div>
247
- </div>
248
- )}
249
-
250
242
  {/* Error state */}
251
243
  {error && !isLoading && (
252
244
  <div className="flex min-h-[200px] items-center justify-center rounded-lg border border-destructive/50 bg-destructive/10 p-8">
@@ -257,25 +249,20 @@ export function LiveInvoicePreview({
257
249
  </div>
258
250
  )}
259
251
 
260
- {/* Empty state */}
261
- {!previewHtml && !isLoading && !error && (
262
- <div className="flex min-h-[200px] items-center justify-center rounded-lg border border-dashed p-8">
263
- <div className="text-center">
264
- <p className="font-semibold text-muted-foreground">{documentTypeLabel || t("Document Preview")}</p>
265
- <p className="text-muted-foreground text-sm">{t("Start filling the form to see a live preview")}</p>
266
- </div>
267
- </div>
268
- )}
252
+ {/* Skeleton: shown on initial load and when loading without existing preview */}
253
+ {showSkeleton && <DocumentPreviewSkeleton />}
269
254
 
270
255
  {/* Preview - Scoped HTML injection with A4 scaling */}
271
256
  {previewHtml && !error && (
272
- <ScaledDocumentPreview
273
- htmlContent={previewHtml}
274
- scale={scale}
275
- contentHeight={contentHeight}
276
- A4_WIDTH_PX={A4_WIDTH_PX}
277
- contentRef={contentRef}
278
- />
257
+ <div className={cn(isLoading && "opacity-50 transition-opacity duration-200")}>
258
+ <ScaledDocumentPreview
259
+ htmlContent={previewHtml}
260
+ scale={scale}
261
+ contentHeight={contentHeight}
262
+ A4_WIDTH_PX={A4_WIDTH_PX}
263
+ contentRef={contentRef}
264
+ />
265
+ </div>
279
266
  )}
280
267
  </div>
281
268
  );
@@ -7,6 +7,7 @@ type CustomerData = {
7
7
  state?: string | null;
8
8
  country?: string | null;
9
9
  tax_number?: string | null;
10
+ is_end_consumer?: boolean | null;
10
11
  save_customer?: boolean;
11
12
  };
12
13
 
@@ -14,6 +14,7 @@ export type CustomerData = {
14
14
  state?: string | null;
15
15
  country?: string | null;
16
16
  tax_number?: string | null;
17
+ is_end_consumer?: boolean | null;
17
18
  };
18
19
 
19
20
  /**
@@ -97,6 +98,7 @@ export function useDocumentCustomerForm<TForm extends DocumentFormWithCustomer>(
97
98
  state: toFormValue(customer.state),
98
99
  country: toFormValue(customer.country),
99
100
  tax_number: toFormValue(customer.tax_number),
101
+ is_end_consumer: customer.is_end_consumer ?? undefined,
100
102
  } as PathValue<TForm, Path<TForm>>,
101
103
  );
102
104
  setOriginalCustomer(null);
@@ -115,6 +117,7 @@ export function useDocumentCustomerForm<TForm extends DocumentFormWithCustomer>(
115
117
  state: toFormValue(customer.state),
116
118
  country: toFormValue(customer.country),
117
119
  tax_number: toFormValue(customer.tax_number),
120
+ is_end_consumer: customer.is_end_consumer ?? undefined,
118
121
  };
119
122
 
120
123
  setValue("customer" as Path<TForm>, customerData as PathValue<TForm, Path<TForm>>);
@@ -140,6 +143,7 @@ export function useDocumentCustomerForm<TForm extends DocumentFormWithCustomer>(
140
143
  state: undefined,
141
144
  country: undefined,
142
145
  tax_number: undefined,
146
+ is_end_consumer: true,
143
147
  } as PathValue<TForm, Path<TForm>>,
144
148
  );
145
149
  setOriginalCustomer(null);
@@ -0,0 +1,63 @@
1
+ import { Skeleton } from "@/ui/components/ui/skeleton";
2
+
3
+ /** A4-shaped skeleton that mimics a document layout */
4
+ export function DocumentPreviewSkeleton() {
5
+ return (
6
+ <div className="rounded-lg border bg-neutral-100 p-4">
7
+ <div className="mx-auto bg-white p-8" style={{ maxWidth: 794, aspectRatio: "210 / 297" }}>
8
+ {/* Header: logo + company info */}
9
+ <div className="flex justify-between">
10
+ <Skeleton className="h-10 w-32 bg-neutral-200" />
11
+ <div className="flex flex-col items-end gap-1.5">
12
+ <Skeleton className="h-3 w-40 bg-neutral-200" />
13
+ <Skeleton className="h-3 w-32 bg-neutral-200" />
14
+ <Skeleton className="h-3 w-28 bg-neutral-200" />
15
+ </div>
16
+ </div>
17
+
18
+ {/* Title */}
19
+ <Skeleton className="mt-8 h-5 w-28 bg-neutral-200" />
20
+
21
+ {/* Recipient + details row */}
22
+ <div className="mt-6 flex justify-between">
23
+ <div className="flex flex-col gap-1.5">
24
+ <Skeleton className="h-3 w-36 bg-neutral-200" />
25
+ <Skeleton className="h-3 w-28 bg-neutral-200" />
26
+ <Skeleton className="h-3 w-24 bg-neutral-200" />
27
+ </div>
28
+ <div className="flex flex-col items-end gap-1.5">
29
+ <Skeleton className="h-3 w-24 bg-neutral-200" />
30
+ <Skeleton className="h-3 w-20 bg-neutral-200" />
31
+ <Skeleton className="h-3 w-24 bg-neutral-200" />
32
+ </div>
33
+ </div>
34
+
35
+ {/* Table header */}
36
+ <div className="mt-8 flex gap-4 border-b pb-2">
37
+ <Skeleton className="h-3 w-8 bg-neutral-200" />
38
+ <Skeleton className="h-3 flex-1 bg-neutral-200" />
39
+ <Skeleton className="h-3 w-12 bg-neutral-200" />
40
+ <Skeleton className="h-3 w-16 bg-neutral-200" />
41
+ <Skeleton className="h-3 w-16 bg-neutral-200" />
42
+ </div>
43
+ {/* Table rows */}
44
+ {[1, 2, 3].map((i) => (
45
+ <div key={i} className="flex gap-4 border-b py-2.5">
46
+ <Skeleton className="h-3 w-8 bg-neutral-200" />
47
+ <Skeleton className="h-3 flex-1 bg-neutral-200" />
48
+ <Skeleton className="h-3 w-12 bg-neutral-200" />
49
+ <Skeleton className="h-3 w-16 bg-neutral-200" />
50
+ <Skeleton className="h-3 w-16 bg-neutral-200" />
51
+ </div>
52
+ ))}
53
+
54
+ {/* Totals */}
55
+ <div className="mt-4 flex flex-col items-end gap-1.5">
56
+ <Skeleton className="h-3 w-32 bg-neutral-200" />
57
+ <Skeleton className="h-3 w-28 bg-neutral-200" />
58
+ <Skeleton className="h-4 w-36 bg-neutral-200" />
59
+ </div>
60
+ </div>
61
+ </div>
62
+ );
63
+ }
@@ -1,3 +1,4 @@
1
1
  export { DocumentPreviewDisplay, InvoicePreviewDisplay } from "./document-preview-display";
2
+ export { DocumentPreviewSkeleton } from "./document-preview-skeleton";
2
3
  export { ScaledDocumentPreview } from "./scaled-document-preview";
3
4
  export { useA4Scaling } from "./use-a4-scaling";
@@ -81,8 +81,10 @@ interface DocumentActionsBarProps extends ComponentTranslationProps {
81
81
  onDuplicate?: (targetType: DocumentType) => void;
82
82
  /** Called when user wants to edit the document */
83
83
  onEdit?: () => void;
84
- /** Whether the document is editable (not voided, not FURS fiscalized) */
84
+ /** Whether the document is editable (not voided, not fiscalized) */
85
85
  isEditable?: boolean;
86
+ /** Reason why editing is disabled (shown as tooltip) */
87
+ editDisabledReason?: string;
86
88
  /** Called when user wants to finalize a draft document */
87
89
  onFinalize?: () => void;
88
90
  /** Whether finalization is in progress */
@@ -119,6 +121,7 @@ export function DocumentActionsBar({
119
121
  onDuplicate,
120
122
  onEdit,
121
123
  isEditable,
124
+ editDisabledReason,
122
125
  onFinalize,
123
126
  isFinalizing,
124
127
  onDeleteDraft,
@@ -233,12 +236,31 @@ export function DocumentActionsBar({
233
236
  )}
234
237
 
235
238
  {/* Edit */}
236
- {onEdit && isEditable && (
237
- <Button variant="outline" size="sm" onClick={onEdit} className="cursor-pointer">
238
- <Pencil className="mr-2 h-4 w-4" />
239
- {t("Edit")}
240
- </Button>
241
- )}
239
+ {onEdit &&
240
+ (isEditable ? (
241
+ <Button variant="outline" size="sm" onClick={onEdit} className="cursor-pointer">
242
+ <Pencil className="mr-2 h-4 w-4" />
243
+ {t("Edit")}
244
+ </Button>
245
+ ) : (
246
+ <Tooltip>
247
+ <TooltipTrigger asChild>
248
+ <Button
249
+ variant="outline"
250
+ size="sm"
251
+ aria-disabled="true"
252
+ className="pointer-events-auto opacity-50"
253
+ onClick={(e) => e.preventDefault()}
254
+ >
255
+ <Pencil className="mr-2 h-4 w-4" />
256
+ {t("Edit")}
257
+ </Button>
258
+ </TooltipTrigger>
259
+ <TooltipContent>
260
+ <p>{editDisabledReason}</p>
261
+ </TooltipContent>
262
+ </Tooltip>
263
+ ))}
242
264
 
243
265
  {/* Share Link */}
244
266
  {shareUrl ? (
@@ -1,10 +1,5 @@
1
1
  import { zodResolver } from "@hookform/resolvers/zod";
2
- import type {
3
- Entity,
4
- EntitySettings,
5
- EntitySettingsTaxClauseDefaults,
6
- TaxRules,
7
- } from "@spaceinvoices/js-sdk";
2
+ import type { Entity, EntitySettings, EntitySettingsTaxClauseDefaults, TaxRules } from "@spaceinvoices/js-sdk";
8
3
  import { ChevronDown, Globe, MessageSquareText } from "lucide-react";
9
4
  import type { ReactNode } from "react";
10
5
  import { useState } from "react";
@@ -39,7 +34,8 @@ const taxRulesSettingsSchema = z.object({
39
34
  require_gross_prices: z.boolean(),
40
35
  // Tax clause defaults per transaction type
41
36
  tax_clause_intra_eu_b2b: z.string().optional(),
42
- tax_clause_export: z.string().optional(),
37
+ tax_clause_3w_b2b: z.string().optional(),
38
+ tax_clause_3w_b2c: z.string().optional(),
43
39
  tax_clause_domestic: z.string().optional(),
44
40
  tax_clause_intra_eu_b2c: z.string().optional(),
45
41
  });
@@ -92,7 +88,8 @@ export function TaxRulesSettingsForm({
92
88
  auto_remove_tax_export: currentTaxRules.auto_remove_tax_export ?? false,
93
89
  require_gross_prices: currentTaxRules.require_gross_prices ?? false,
94
90
  tax_clause_intra_eu_b2b: currentTaxClauseDefaults.intra_eu_b2b ?? "",
95
- tax_clause_export: currentTaxClauseDefaults.export ?? "",
91
+ tax_clause_3w_b2b: (currentTaxClauseDefaults as any)["3w_b2b"] ?? currentTaxClauseDefaults.export ?? "",
92
+ tax_clause_3w_b2c: (currentTaxClauseDefaults as any)["3w_b2c"] ?? currentTaxClauseDefaults.export ?? "",
96
93
  tax_clause_domestic: currentTaxClauseDefaults.domestic ?? "",
97
94
  tax_clause_intra_eu_b2c: currentTaxClauseDefaults.intra_eu_b2c ?? "",
98
95
  },
@@ -127,7 +124,8 @@ export function TaxRulesSettingsForm({
127
124
  },
128
125
  tax_clause_defaults: {
129
126
  intra_eu_b2b: values.tax_clause_intra_eu_b2b || null,
130
- export: values.tax_clause_export || null,
127
+ "3w_b2b": values.tax_clause_3w_b2b || null,
128
+ "3w_b2c": values.tax_clause_3w_b2c || null,
131
129
  domestic: values.tax_clause_domestic || null,
132
130
  intra_eu_b2c: values.tax_clause_intra_eu_b2c || null,
133
131
  },
@@ -288,14 +286,34 @@ export function TaxRulesSettingsForm({
288
286
  )}
289
287
  />
290
288
 
291
- {/* Export */}
289
+ {/* 3W B2B (non-EU business) */}
292
290
  <FormField
293
291
  control={form.control}
294
- name="tax_clause_export"
292
+ name="tax_clause_3w_b2b"
295
293
  render={({ field }) => (
296
294
  <FormItem className="rounded-lg border p-4">
297
- <FormLabel>{t("tax-clauses.export.label")}</FormLabel>
298
- <FormDescription>{t("tax-clauses.export.description")}</FormDescription>
295
+ <FormLabel>{t("tax-clauses.3w_b2b.label")}</FormLabel>
296
+ <FormDescription>{t("tax-clauses.3w_b2b.description")}</FormDescription>
297
+ <FormControl>
298
+ <Textarea
299
+ placeholder={t("Enter export exemption clause...")}
300
+ className="min-h-[80px] resize-y"
301
+ {...field}
302
+ />
303
+ </FormControl>
304
+ <FormMessage />
305
+ </FormItem>
306
+ )}
307
+ />
308
+
309
+ {/* 3W B2C (non-EU consumer) */}
310
+ <FormField
311
+ control={form.control}
312
+ name="tax_clause_3w_b2c"
313
+ render={({ field }) => (
314
+ <FormItem className="rounded-lg border p-4">
315
+ <FormLabel>{t("tax-clauses.3w_b2c.label")}</FormLabel>
316
+ <FormDescription>{t("tax-clauses.3w_b2c.description")}</FormDescription>
299
317
  <FormControl>
300
318
  <Textarea
301
319
  placeholder={t("Enter export exemption clause...")}
@@ -11,7 +11,7 @@ import { Form } from "@/ui/components/ui/form";
11
11
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/ui/components/ui/tooltip";
12
12
  import { createEstimateSchema } from "@/ui/generated/schemas";
13
13
  import { useNextDocumentNumber } from "@/ui/hooks/use-next-document-number";
14
- import { useViesCheck } from "@/ui/hooks/use-vies-check";
14
+ import { useTransactionTypeCheck } from "@/ui/hooks/use-transaction-type-check";
15
15
  import type { ComponentTranslationProps } from "@/ui/lib/translation";
16
16
  import { createTranslation } from "@/ui/lib/translation";
17
17
  import { useEntities } from "@/ui/providers/entities-context";
@@ -259,12 +259,13 @@ export default function CreateEstimateForm({
259
259
  transactionType,
260
260
  isFetching: isViesFetching,
261
261
  warning: viesWarning,
262
- } = useViesCheck({
262
+ } = useTransactionTypeCheck({
263
263
  issuerCountryCode: activeEntity?.country_code,
264
264
  isTaxSubject: activeEntity?.is_tax_subject ?? true,
265
265
  customerCountry: formValues.customer?.country,
266
266
  customerCountryCode: formValues.customer?.country_code,
267
267
  customerTaxNumber: formValues.customer?.tax_number,
268
+ customerIsEndConsumer: (formValues.customer as any)?.is_end_consumer,
268
269
  enabled: !!activeEntity,
269
270
  });
270
271