@apptimate/ui 4.1.0 → 4.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -5,7 +5,7 @@ import { Briefcase, CreditCard, Settings, Menu, X, LogOut, User as UserIcon, Bui
5
5
  import { Dropdown, DropdownItem } from '../base-components/Dropdown';
6
6
 
7
7
  import Link from 'next/link';
8
- import { usePathname } from 'next/navigation';
8
+ import { usePathname, useRouter } from 'next/navigation';
9
9
  import { cn } from '@apptimate/core-lib';
10
10
 
11
11
  export type DashboardMenuConfig = {
@@ -68,6 +68,7 @@ export function DashboardLayout({
68
68
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
69
69
  const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
70
70
  const pathname = usePathname() || "";
71
+ const router = useRouter();
71
72
 
72
73
  // Find which main menu should be active based on current path.
73
74
  // When basePath is set, prefer the menu whose items are mostly within basePath.
@@ -106,6 +107,26 @@ export function DashboardLayout({
106
107
 
107
108
  const activeMenu = menus.find(m => m.id === activeMenuId) || menus[0];
108
109
 
110
+ const handleMainMenuSelect = (menu: DashboardMenuConfig) => {
111
+ setActiveMenuId(menu.id);
112
+
113
+ const firstItem = menu.groups.flatMap(group => group.items)[0];
114
+ if (!firstItem) return;
115
+
116
+ const isInternal = basePath
117
+ ? firstItem.path.startsWith(basePath)
118
+ : !externalPaths.some(path => firstItem.path.startsWith(path));
119
+ const href = basePath && isInternal
120
+ ? firstItem.path.replace(basePath, "") || "/"
121
+ : firstItem.path;
122
+
123
+ if (isInternal) {
124
+ router.push(href);
125
+ } else {
126
+ window.location.href = href;
127
+ }
128
+ };
129
+
109
130
  const handleSelectOrg = (org: OrganizationConfig) => {
110
131
  onOrganizationChange?.(org);
111
132
  setIsOrgModalOpen(false);
@@ -158,7 +179,7 @@ export function DashboardLayout({
158
179
  return (
159
180
  <div
160
181
  key={menu.id}
161
- onClick={() => setActiveMenuId(menu.id)}
182
+ onClick={() => handleMainMenuSelect(menu)}
162
183
  className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-colors w-full px-1 ${isActive ? "text-[#2D3142]" : "text-gray-400 hover:text-[#2D3142]"
163
184
  }`}
164
185
  >
@@ -9,6 +9,7 @@ import {
9
9
  } from 'lucide-react';
10
10
  import { getPaymentModes, getBankAccountsLookup } from '@apptimate/core-lib';
11
11
  import { PartyPicker, WarehousePicker } from '../pickers';
12
+ import { PaymentSection } from '../PaymentSection';
12
13
  import type {
13
14
  TransactionScreenConfig, PaymentMode, PaymentEntry, BankAccount,
14
15
  PartyLookup, PaymentModeField, TransactionLineItem,
@@ -34,12 +35,13 @@ interface MetadataPanelProps {
34
35
  onSubmit: () => void;
35
36
  isSubmitting: boolean;
36
37
  renderExtraMeta?: () => React.ReactNode;
38
+ fetchChequeLeaves?: (query: string, page: number) => Promise<{ is_success: boolean; result?: any[] }>;
37
39
  }
38
40
 
39
41
  export function MetadataPanel({
40
42
  config, lineItems, partyId, partyName, warehouseId, warehouseName, transactionDate, expectedDeliveryDate, notes, payments,
41
43
  onPartyChange, onWarehouseChange, onDateChange, onExpectedDeliveryDateChange, onNotesChange, onPaymentsChange, onSubmit, isSubmitting,
42
- renderExtraMeta,
44
+ renderExtraMeta, fetchChequeLeaves,
43
45
  }: MetadataPanelProps) {
44
46
  const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
45
47
  const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
@@ -226,77 +228,15 @@ export function MetadataPanel({
226
228
  {/* ── Payment Section ── */}
227
229
  {config.showPayment && (
228
230
  <Section label="Payment">
229
- {isLoadingMeta ? (
230
- <div className="flex items-center justify-center py-4">
231
- <Loader2 size={18} className="animate-spin text-primary" />
232
- </div>
233
- ) : (
234
- <div className="space-y-2.5">
235
- {/* Existing payment entries */}
236
- {payments.map((entry, idx) => {
237
- const mode = paymentModes.find((m) => m.id === entry.mode_id);
238
- return (
239
- <PaymentEntryRow
240
- key={idx}
241
- entry={entry}
242
- mode={mode}
243
- bankAccounts={bankAccounts}
244
- onUpdate={(updates) => updatePayment(idx, updates)}
245
- onRemove={() => removePayment(idx)}
246
- />
247
- );
248
- })}
249
-
250
- {/* Add payment mode buttons */}
251
- {(lineItems.length > 0) && (
252
- <div className="flex flex-wrap gap-2 pt-1">
253
- {paymentModes
254
- .filter((m) => m.is_active)
255
- .map((mode) => {
256
- const modeIcon = mode.code === 'cash' ? <Banknote size={14} /> :
257
- mode.code === 'card' ? <CreditCard size={14} /> :
258
- mode.code === 'bank_transfer' ? <Building size={14} /> :
259
- <CreditCard size={14} />;
260
- return (
261
- <button
262
- key={mode.id}
263
- onClick={() => addPayment(mode)}
264
- className="inline-flex items-center gap-2 px-4 py-2 text-[12px] font-semibold rounded-[10px] bg-surface-0 border border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary transition-all"
265
- >
266
- {modeIcon}
267
- {mode.name}
268
- </button>
269
- );
270
- })}
271
- </div>
272
- )}
273
-
274
- {/* Balance */}
275
- {payments.length > 0 && (
276
- <div className="bg-surface-0 rounded-[10px] border border-border-subtle p-3.5 mt-2 space-y-1.5">
277
- <TotalRow label="Total Paid" value={formatCurrency(totalPaid)} />
278
- <TotalRow
279
- label={balance > 0.01 ? "Balance Due" : balance < -0.01 ? "Change Due" : "Settled"}
280
- value={formatCurrency(Math.abs(balance))}
281
- className={balance > 0.01 ? 'text-danger-alt' : balance < -0.01 ? 'text-amber-600' : 'text-green-600'}
282
- bold
283
- />
284
- {balance < -0.01 && (
285
- <div className="flex items-center gap-1.5 mt-1 text-[11px] text-amber-600 font-medium">
286
- <AlertCircle size={12} />
287
- <span>Change: {formatCurrency(Math.abs(balance))}</span>
288
- </div>
289
- )}
290
- {Math.abs(balance) < 0.01 && (
291
- <div className="flex items-center gap-1.5 mt-1 text-[11px] text-green-600 font-medium">
292
- <Check size={12} />
293
- <span>Fully paid</span>
294
- </div>
295
- )}
296
- </div>
297
- )}
298
- </div>
299
- )}
231
+ <PaymentSection
232
+ totalAmount={grandTotal}
233
+ payments={payments}
234
+ onPaymentsChange={onPaymentsChange}
235
+ fetchPaymentModes={getPaymentModes}
236
+ fetchBankAccounts={getBankAccountsLookup}
237
+ fetchChequeLeaves={fetchChequeLeaves}
238
+ compact={true}
239
+ />
300
240
  </Section>
301
241
  )}
302
242
 
@@ -312,9 +312,10 @@ export function ProductSelectionPanel({
312
312
  return (
313
313
  <div className="flex flex-col h-full">
314
314
  {/* ── Search Bar ── */}
315
- <div ref={panelRef} className="relative mb-4">
316
- <div className="flex items-center gap-2 bg-surface-0 border-[1.5px] border-border-subtle rounded-[12px] px-4 py-2.5 focus-within:border-primary/50 transition-all">
317
- <Search size={18} className="text-foreground-disabled shrink-0" />
315
+ {!config.disableItemSelection && (
316
+ <div ref={panelRef} className="relative mb-4">
317
+ <div className="flex items-center gap-2 bg-surface-0 border-[1.5px] border-border-subtle rounded-[12px] px-4 py-2.5 focus-within:border-primary/50 transition-all">
318
+ <Search size={18} className="text-foreground-disabled shrink-0" />
318
319
  <input
319
320
  ref={inputRef}
320
321
  type="text"
@@ -513,6 +514,7 @@ export function ProductSelectionPanel({
513
514
  </div>
514
515
  )}
515
516
  </div>
517
+ )}
516
518
 
517
519
  {/* ── Selected Items Table ── */}
518
520
  <div className="flex-1 overflow-y-auto custom-scrollbar w-full">
@@ -549,9 +551,16 @@ export function ProductSelectionPanel({
549
551
  )}
550
552
  </div>
551
553
  <div className="flex flex-col flex-1 min-w-0">
552
- <span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">{line.item.name}</span>
554
+ <span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">
555
+ {line.item.name}
556
+ {line.is_free_qty && (
557
+ <span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-green-100 text-green-700 uppercase tracking-wider align-middle">
558
+ Free
559
+ </span>
560
+ )}
561
+ </span>
553
562
  <span className="text-[11px] text-foreground-subtle font-mono mt-0.5 break-words">
554
- {line.variant_id && line.item.variants?.find(v => v.id === line.variant_id)
563
+ {line.item.has_variants && line.variant_id && line.item.variants?.find(v => v.id === line.variant_id)
555
564
  ? `${line.item.variants.find(v => v.id === line.variant_id)?.variant_name} • ${line.item.variants.find(v => v.id === line.variant_id)?.sku}`
556
565
  : line.item.sku}
557
566
  </span>
@@ -610,23 +619,36 @@ export function ProductSelectionPanel({
610
619
  })()}
611
620
  </div>
612
621
  )}
613
- {(config.type === 'purchase' || config.type === 'grn') && line.unit_price === 0 && (
622
+ {(config.type === 'purchase' || config.type === 'grn') && Number(line.unit_price) === 0 && (
614
623
  <div className="mt-2 flex items-center gap-2">
615
- <label className="flex items-center gap-1.5 cursor-pointer">
624
+ <label className={cn("flex items-center gap-1.5", !!line.purchase_order_line_id ? "cursor-not-allowed" : "cursor-pointer")}>
616
625
  <input
617
626
  type="checkbox"
618
627
  checked={line.is_free_qty || false}
619
628
  onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
620
- className="rounded border-border-subtle text-primary focus:ring-primary h-3 w-3"
629
+ disabled={!!line.purchase_order_line_id}
630
+ className={cn(
631
+ "rounded border-border-subtle text-primary h-3 w-3",
632
+ !!line.purchase_order_line_id ? "opacity-50 cursor-not-allowed" : "focus:ring-primary"
633
+ )}
621
634
  />
622
- <span className="text-[11.5px] font-medium text-foreground-subtle hover:text-foreground-1 transition-colors">Free Quantity</span>
635
+ <span className={cn(
636
+ "text-[11.5px] font-medium transition-colors",
637
+ !!line.purchase_order_line_id ? "text-foreground-disabled" : "text-foreground-subtle hover:text-foreground-1"
638
+ )}>
639
+ Free Quantity
640
+ </span>
623
641
  </label>
624
642
 
625
643
  {line.is_free_qty && (
626
644
  <div className="flex items-center">
627
645
  <button
628
646
  onClick={() => setActiveParentLineUidFor(line.uid)}
629
- className="h-6 text-[11.5px] bg-surface-1 border border-border-subtle hover:bg-surface-hover rounded-md px-2 ml-2 min-w-[120px] max-w-[180px] text-left truncate transition-colors focus:border-primary/50 focus:outline-none flex justify-between items-center"
647
+ disabled={!!line.purchase_order_line_id}
648
+ className={cn(
649
+ "h-6 text-[11.5px] bg-surface-1 border border-border-subtle rounded-md px-2 ml-2 min-w-[120px] max-w-[180px] text-left truncate transition-colors flex justify-between items-center",
650
+ !!line.purchase_order_line_id ? "opacity-60 cursor-not-allowed" : "hover:bg-surface-hover focus:border-primary/50 focus:outline-none"
651
+ )}
630
652
  >
631
653
  <span>
632
654
  {line.parent_line_uids && line.parent_line_uids.length > 0
@@ -634,13 +656,9 @@ export function ProductSelectionPanel({
634
656
  if (line.parent_line_uids.length === 1) {
635
657
  const p = lineItems.find((l) => l.uid === line.parent_line_uids![0]);
636
658
  return p
637
- ? `${p.item.name} ${
638
- p.variant_id
639
- ? `(${
640
- p.item.variants?.find((v) => v.id === p.variant_id)?.variant_name
641
- })`
642
- : ""
643
- }`
659
+ ? p.item.has_variants && p.variant_id && p.item.variants?.find((v) => v.id === p.variant_id)
660
+ ? `${p.item.name} (${p.item.variants.find((v) => v.id === p.variant_id)?.variant_name})`
661
+ : p.item.name
644
662
  : "Select Parent Line...";
645
663
  } else {
646
664
  return `${line.parent_line_uids.length} Parents Selected`;
@@ -674,7 +692,8 @@ export function ProductSelectionPanel({
674
692
  <select
675
693
  value={line.uom_id || line.item.uom_id || ''}
676
694
  onChange={(e) => updateLineInput(line.uid, 'uom_id', e.target.value)}
677
- className="h-8 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors min-w-[80px]"
695
+ disabled={line.item.tracking_type === 'serial'}
696
+ className="h-8 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors min-w-[80px] disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
678
697
  >
679
698
  {line.item.uom?.uom_group?.uoms?.map((uom: any) => (
680
699
  <option key={uom.id} value={uom.id}>{uom.abbreviation}</option>
@@ -687,7 +706,7 @@ export function ProductSelectionPanel({
687
706
  <TCell label="Rate" className="py-2.5">
688
707
  <input
689
708
  type="text"
690
- value={editingRates[line.uid] ?? line.unit_price.toFixed(2)}
709
+ value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
691
710
  onChange={(e) => {
692
711
  const val = e.target.value;
693
712
  if (/^\d*\.?\d{0,2}$/.test(val)) {
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useCallback, useMemo } from 'react';
3
+ import React, { useState, useCallback, useMemo, useEffect } from 'react';
4
4
  import { cn } from '@apptimate/core-lib';
5
+ import toast from 'react-hot-toast';
5
6
  import { ProductSelectionPanel } from './ProductSelectionPanel';
6
7
  import { MetadataPanel } from './MetadataPanel';
7
8
  import type {
@@ -25,6 +26,8 @@ export function ProductTransactionScreen({
25
26
  onSubmit,
26
27
  isSubmitting: externalSubmitting,
27
28
  renderExtraMeta,
29
+ fetchChequeLeaves,
30
+ initialData,
28
31
  }: ProductTransactionScreenProps) {
29
32
  // ── State ──
30
33
  const [lineItems, setLineItems] = useState<TransactionLineItem[]>([]);
@@ -42,6 +45,43 @@ export function ProductTransactionScreen({
42
45
 
43
46
  const submitting = externalSubmitting ?? isSubmitting;
44
47
 
48
+ useEffect(() => {
49
+ if (initialData) {
50
+ if (initialData.party_id) {
51
+ setPartyId(initialData.party_id);
52
+ if (initialData.party?.name) setPartyName(initialData.party.name);
53
+ }
54
+ if (initialData.warehouse_id) {
55
+ setWarehouseId(initialData.warehouse_id);
56
+ }
57
+ if (initialData.lines && Array.isArray(initialData.lines)) {
58
+ setLineItems(
59
+ initialData.lines.map((l: any) => {
60
+ const qty = l.quantity || 1;
61
+ const unitPrice = l.unit_price || 0;
62
+ return {
63
+ uid: l.uid || Math.random().toString(36).slice(2, 7),
64
+ item: l.item,
65
+ variant_id: l.variant_id || null,
66
+ batches: [],
67
+ serial_numbers: [],
68
+ quantity: qty,
69
+ unit_price: unitPrice,
70
+ discount_percent: 0,
71
+ discount_amount: 0,
72
+ line_total: unitPrice * qty,
73
+ expanded: false,
74
+ is_free_qty: l.is_free_qty,
75
+ parent_line_uids: l.parent_line_uids,
76
+ uom_id: l.uom_id,
77
+ purchase_order_line_id: l.purchase_order_line_id,
78
+ };
79
+ })
80
+ );
81
+ }
82
+ }
83
+ }, [initialData]);
84
+
45
85
  // ── Add item to line items ──
46
86
  const handleAddItem = useCallback(
47
87
  (
@@ -80,6 +120,7 @@ export function ProductTransactionScreen({
80
120
  discount_amount: discountAmt,
81
121
  line_total: unitPrice * qty - discountAmt,
82
122
  expanded: false,
123
+ uom_id: itemWithVariants.uom_id,
83
124
  };
84
125
  return [...prevLineItems, newLine];
85
126
  });
@@ -115,6 +156,33 @@ export function ProductTransactionScreen({
115
156
  const handleSubmit = useCallback(async () => {
116
157
  if (lineItems.length === 0) return;
117
158
 
159
+ if (config.requireFullPayment) {
160
+ const totalPayments = payments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
161
+ if (Math.abs(totalPayments - grandTotal) > 0.01) {
162
+ toast.error('Total payments must equal the Grand Total (Use Credit mode to pay later).');
163
+ return;
164
+ }
165
+ }
166
+
167
+ if (!config.disableTrackingSelection) {
168
+ for (const line of lineItems) {
169
+ if (line.item.tracking_type === 'batch') {
170
+ const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
171
+ if (selectedTotal !== line.quantity) {
172
+ toast.error(`Please select batches for ${line.item.name} to match the total quantity.`);
173
+ return;
174
+ }
175
+ }
176
+ if (line.item.tracking_type === 'serial') {
177
+ const selectedCount = line.serial_numbers?.length || 0;
178
+ if (selectedCount !== line.quantity) {
179
+ toast.error(`Please select exactly ${line.quantity} serial numbers for ${line.item.name}.`);
180
+ return;
181
+ }
182
+ }
183
+ }
184
+ }
185
+
118
186
  const payload: TransactionPayload = {
119
187
  type: config.type,
120
188
  party_id: partyId,
@@ -139,6 +207,7 @@ export function ProductTransactionScreen({
139
207
  is_free_qty: l.is_free_qty,
140
208
  parent_line_uids: l.parent_line_uids,
141
209
  uom_id: l.uom_id,
210
+ purchase_order_line_id: l.purchase_order_line_id,
142
211
  unit_price: l.unit_price,
143
212
  discount_percent: l.discount_percent,
144
213
  discount_amount: (l.discount_amount / l.quantity) * b.quantity,
@@ -160,6 +229,7 @@ export function ProductTransactionScreen({
160
229
  is_free_qty: l.is_free_qty,
161
230
  parent_line_uids: l.parent_line_uids,
162
231
  uom_id: l.uom_id,
232
+ purchase_order_line_id: l.purchase_order_line_id,
163
233
  unit_price: l.unit_price,
164
234
  discount_percent: l.discount_percent,
165
235
  discount_amount: l.discount_amount,
@@ -234,6 +304,7 @@ export function ProductTransactionScreen({
234
304
  onSubmit={handleSubmit}
235
305
  isSubmitting={submitting}
236
306
  renderExtraMeta={renderExtraMeta}
307
+ fetchChequeLeaves={fetchChequeLeaves}
237
308
  />
238
309
  </div>
239
310
  </div>
@@ -77,6 +77,7 @@ export interface TransactionLineItem {
77
77
  is_free_qty?: boolean;
78
78
  parent_line_uids?: string[];
79
79
  uom_id?: number;
80
+ purchase_order_line_id?: number | null;
80
81
  unit_price: number;
81
82
  discount_percent: number;
82
83
  discount_amount: number;
@@ -137,6 +138,8 @@ export interface TransactionScreenConfig {
137
138
  partyIsWarehouse?: boolean;
138
139
  /** Whether to show the party selector at all (defaults to true) */
139
140
  showParty?: boolean;
141
+ /** Disable the search bar (useful for PO to GRN conversions where items are fixed) */
142
+ disableItemSelection?: boolean;
140
143
  /** Allow creating new batches and serials during selection */
141
144
  allowCreateBatchAndSerial?: boolean;
142
145
  /** If true, user must select a warehouse before searching/adding items */
@@ -151,6 +154,8 @@ export interface TransactionScreenConfig {
151
154
  showExpectedDeliveryDate?: boolean;
152
155
  /** Disable batch/serial selection completely (e.g. for Purchase Orders and Quotations) */
153
156
  disableTrackingSelection?: boolean;
157
+ /** Enforce that the sum of payments equals the grand total before allowing submission */
158
+ requireFullPayment?: boolean;
154
159
  }
155
160
 
156
161
  // ── Module Presets ──
@@ -263,7 +268,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
263
268
  priceField: 'default_cost_price',
264
269
  showPrices: true,
265
270
  allowDiscount: true,
266
- showPayment: false,
271
+ showPayment: true,
267
272
  partyLabel: 'Supplier',
268
273
  partyType: 'supplier',
269
274
  showWarehouse: true,
@@ -273,6 +278,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
273
278
  allowCreateBatchAndSerial: true,
274
279
  allowEditPrice: true,
275
280
  showStock: true,
281
+ requireFullPayment: true,
276
282
  },
277
283
  };
278
284
 
@@ -289,6 +295,10 @@ export interface ProductTransactionScreenProps {
289
295
  isSubmitting?: boolean;
290
296
  /** Optional extra content rendered at the top of the metadata (right) panel */
291
297
  renderExtraMeta?: () => React.ReactNode;
298
+ /** Fetcher for cheque leaves (for auto-complete) */
299
+ fetchChequeLeaves?: (query: string, page: number) => Promise<{ is_success: boolean; result?: any[] }>;
300
+ /** Initial data for populating the transaction screen (e.g., from a PO) */
301
+ initialData?: any;
292
302
  }
293
303
 
294
304
  export interface TransactionPayload {
@@ -313,6 +323,7 @@ export interface TransactionPayload {
313
323
  is_free_qty?: boolean;
314
324
  parent_line_uids?: string[];
315
325
  uom_id?: number;
326
+ purchase_order_line_id?: number | null;
316
327
  unit_price: number;
317
328
  discount_percent: number;
318
329
  discount_amount: number;