@apptimate/ui 7.1.0 → 7.3.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.
@@ -56,6 +56,8 @@ export interface PaymentSectionProps {
56
56
  onPaymentsChange: (payments: PaymentEntry[]) => void;
57
57
  /** Optional: show compact variant */
58
58
  compact?: boolean;
59
+ /** When true, initially show only Cash/Card with a 'More Options' toggle for other modes (used in POS) */
60
+ collapseModes?: boolean;
59
61
  /** Fetcher for payment modes */
60
62
  fetchPaymentModes: () => Promise<{ is_success: boolean; result?: any }>;
61
63
  /** Fetcher for bank accounts */
@@ -72,11 +74,12 @@ export interface PaymentSectionProps {
72
74
 
73
75
  /* ── Component ── */
74
76
 
75
- export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [], fetchChequeLeaves, variant = "default" }: PaymentSectionProps) {
77
+ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, collapseModes, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [], fetchChequeLeaves, variant = "default" }: PaymentSectionProps) {
76
78
  const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
77
79
  const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
78
80
  const [isLoading, setIsLoading] = useState(false);
79
81
  const [selectionMode, setSelectionMode] = useState<"single" | "split">("single");
82
+ const [showMoreModes, setShowMoreModes] = useState(false);
80
83
  const paymentsRef = useRef(payments);
81
84
 
82
85
  // Keep ref in sync
@@ -180,11 +183,14 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
180
183
 
181
184
  // ── Auto-sync single mode amount when totalAmount changes ──
182
185
  useEffect(() => {
183
- if (selectionMode === "single" && payments.length === 1) {
184
- updatePayment(0, { amount: totalAmount });
186
+ if (selectionMode === "single" && paymentsRef.current.length === 1 && totalAmount > 0) {
187
+ const current = paymentsRef.current[0];
188
+ if (current.amount !== totalAmount) {
189
+ onPaymentsChange([{ ...current, amount: totalAmount }]);
190
+ }
185
191
  }
186
192
  // eslint-disable-next-line react-hooks/exhaustive-deps
187
- }, [totalAmount, selectionMode]); // intentionally omitting `payments` to allow manual partial payments
193
+ }, [totalAmount, selectionMode, payments.length]); // payments.length triggers sync after initial cash auto-selection
188
194
 
189
195
  const activateSplitMode = useCallback(() => {
190
196
  setSelectionMode("split");
@@ -214,41 +220,79 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
214
220
  return (
215
221
  <div className="space-y-3">
216
222
  {/* ── Mode Selection Buttons ── */}
217
- <div className="flex flex-wrap gap-2">
218
- {paymentModes
219
- .filter((m) => m.is_active)
220
- .map((mode) => {
221
- const isActive = selectionMode === "single" && payments.length === 1 && payments[0].mode_id === mode.id;
222
- return (
223
- <button key={mode.id} type="button" onClick={() => selectSingleMode(mode)}
223
+ {(() => {
224
+ const activeModes = paymentModes.filter((m) => m.is_active);
225
+ const primaryModes = activeModes.filter((m) => ["cash", "card"].includes(m.code));
226
+ const secondaryModes = activeModes.filter((m) => !["cash", "card"].includes(m.code));
227
+ // If a secondary mode is currently selected, auto-expand
228
+ const isSecondaryActive = selectionMode === "single" && payments.length === 1 && secondaryModes.some(m => m.id === payments[0].mode_id);
229
+ const shouldShowMore = !collapseModes || showMoreModes || isSecondaryActive || selectionMode === "split";
230
+
231
+ return (
232
+ <div className="flex flex-wrap gap-2">
233
+ {primaryModes.map((mode) => {
234
+ const isActive = selectionMode === "single" && payments.length === 1 && payments[0].mode_id === mode.id;
235
+ return (
236
+ <button key={mode.id} type="button" onClick={() => selectSingleMode(mode)}
237
+ className={cn(
238
+ "inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
239
+ isActive
240
+ ? "bg-primary text-primary-foreground border-primary shadow-sm"
241
+ : "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
242
+ )}>
243
+ {getModeIcon(mode.code)}
244
+ {mode.name}
245
+ </button>
246
+ );
247
+ })}
248
+
249
+ {/* More Options toggle */}
250
+ {secondaryModes.length > 0 && !shouldShowMore && (
251
+ <button type="button" onClick={() => setShowMoreModes(true)}
252
+ className="inline-flex items-center gap-1.5 px-3.5 py-2.5 text-[12px] font-semibold rounded-[10px] border border-dashed border-border-subtle text-foreground-subtle hover:border-primary/40 hover:text-primary hover:bg-primary/5 transition-all">
253
+ <span className="text-[14px]">···</span>
254
+ More Options
255
+ </button>
256
+ )}
257
+
258
+ {/* Secondary modes (shown when expanded) */}
259
+ {shouldShowMore && secondaryModes.map((mode) => {
260
+ const isActive = selectionMode === "single" && payments.length === 1 && payments[0].mode_id === mode.id;
261
+ return (
262
+ <button key={mode.id} type="button" onClick={() => selectSingleMode(mode)}
263
+ className={cn(
264
+ "inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
265
+ isActive
266
+ ? "bg-primary text-primary-foreground border-primary shadow-sm"
267
+ : "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
268
+ )}>
269
+ {getModeIcon(mode.code)}
270
+ {mode.name}
271
+ </button>
272
+ );
273
+ })}
274
+
275
+ {/* Split Button (shown when expanded) */}
276
+ {shouldShowMore && (
277
+ <button type="button" onClick={activateSplitMode}
224
278
  className={cn(
225
279
  "inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
226
- isActive
280
+ selectionMode === "split"
227
281
  ? "bg-primary text-primary-foreground border-primary shadow-sm"
228
282
  : "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
229
283
  )}>
230
- {getModeIcon(mode.code)}
231
- {mode.name}
284
+ <Split size={14} />
285
+ Multiple Methods
232
286
  </button>
233
- );
234
- })}
235
- {/* Split Button */}
236
- <button type="button" onClick={activateSplitMode}
237
- className={cn(
238
- "inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
239
- selectionMode === "split"
240
- ? "bg-primary text-primary-foreground border-primary shadow-sm"
241
- : "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
242
- )}>
243
- <Split size={14} />
244
- Multiple Methods
245
- </button>
246
- </div>
287
+ )}
288
+ </div>
289
+ );
290
+ })()}
247
291
 
248
292
  {/* ── Single Mode: Entry with fields (no delete) ── */}
249
293
  {selectionMode === "single" && payments.length === 1 && (
250
294
  <PaymentEntryRow entry={payments[0]} mode={paymentModes.find((m) => m.id === payments[0].mode_id)}
251
- bankAccounts={bankAccounts} materialTypes={materialTypes} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => {}} showRemove={false} fetchChequeLeaves={fetchChequeLeaves} />
295
+ bankAccounts={bankAccounts} materialTypes={materialTypes} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => { }} showRemove={false} fetchChequeLeaves={fetchChequeLeaves} />
252
296
  )}
253
297
 
254
298
  {/* ── Split Mode: Multi-entry UI ── */}
@@ -381,137 +425,138 @@ function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpda
381
425
  }
382
426
 
383
427
  return (
384
- <div key={field.key} className="flex flex-col gap-1">
385
- <label className="text-[11px] text-foreground-subtle font-bold uppercase tracking-wider flex items-center">
386
- {field.label}
387
- {field.required && <span className="text-danger-alt ml-0.5">*</span>}
388
- {field.key === "cheque_number" && fetchChequeLeaves && !!entry.metadata[field.key] && (
389
- <span className="ml-1.5 flex items-center">
390
- {entry.metadata.cheque_leaf_id ? (
391
- <HintIcon
392
- text="Selected from your cheque book. This will be automatically issued."
393
- icon={<CheckCircle size={13} className="text-green-500" />}
394
- />
395
- ) : (
396
- <HintIcon
397
- text="Not selected from cheque book. This cheque will not be auto-issued."
398
- icon={<AlertTriangle size={13} className="text-amber-500" />}
399
- />
400
- )}
401
- </span>
402
- )}
403
- </label>
404
- {field.type === "bank_account_select" ? (
405
- <select value={entry.metadata[field.key] || ""}
406
- onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
407
- disabled={field.readonly}
408
- required={field.required}
409
- className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
410
- <option value="">Select...</option>
411
- {bankAccounts.map((b) => (<option key={b.id} value={b.id}>{b.bank_name} - {b.account_number}</option>))}
412
- </select>
413
- ) : field.type === "material_type_select" ? (
414
- <select value={entry.metadata[field.key] || ""}
415
- onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
416
- disabled={field.readonly}
417
- required={field.required}
418
- className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
419
- <option value="">Select Material...</option>
420
- {materialTypes.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>))}
421
- </select>
422
- ) : field.type === "select" ? (
423
- <select value={entry.metadata[field.key] || ""}
424
- onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value } })}
425
- disabled={field.readonly}
426
- required={field.required}
427
- className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
428
- <option value="">Select...</option>
429
- {field.options?.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}
430
- </select>
431
- ) : field.type === "multi_select" as any ? (
432
- <AsyncSearchableSelect
433
- multiple
434
- option={{ label: 'label', value: 'value' }}
435
- defaultValue={
436
- Array.isArray(entry.metadata[field.key])
437
- ? (field.options || []).filter(o => (entry.metadata[field.key] as any).includes(o.value))
438
- : []
439
- }
440
- onChange={(val, selectedObjs: any) => {
441
- const sum = Array.isArray(selectedObjs)
442
- ? selectedObjs.reduce((acc, curr) => acc + (curr.unapplied_amount || 0), 0)
443
- : 0;
444
-
445
- onUpdate({
446
- amount: sum,
447
- metadata: { ...entry.metadata, [field.key]: val as any }
448
- });
449
- }}
450
- disabled={field.readonly}
451
- required={field.required}
452
- placeholder="Select..."
453
- loadOptions={async () => field.options || []}
454
- />
455
- ) : field.key === "cheque_number" && fetchChequeLeaves ? (
456
- <>
428
+ <div key={field.key} className="flex flex-col gap-1">
429
+ <label className="text-[11px] text-foreground-subtle font-bold uppercase tracking-wider flex items-center">
430
+ {field.label}
431
+ {field.required && <span className="text-danger-alt ml-0.5">*</span>}
432
+ {field.key === "cheque_number" && fetchChequeLeaves && !!entry.metadata[field.key] && (
433
+ <span className="ml-1.5 flex items-center">
434
+ {entry.metadata.cheque_leaf_id ? (
435
+ <HintIcon
436
+ text="Selected from your cheque book. This will be automatically issued."
437
+ icon={<CheckCircle size={13} className="text-green-500" />}
438
+ />
439
+ ) : (
440
+ <HintIcon
441
+ text="Not selected from cheque book. This cheque will not be auto-issued."
442
+ icon={<AlertTriangle size={13} className="text-amber-500" />}
443
+ />
444
+ )}
445
+ </span>
446
+ )}
447
+ </label>
448
+ {field.type === "bank_account_select" ? (
449
+ <select value={entry.metadata[field.key] || ""}
450
+ onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
451
+ disabled={field.readonly}
452
+ required={field.required}
453
+ className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
454
+ <option value="">Select...</option>
455
+ {bankAccounts.map((b) => (<option key={b.id} value={b.id}>{b.bank_name} - {b.account_number}</option>))}
456
+ </select>
457
+ ) : field.type === "material_type_select" ? (
458
+ <select value={entry.metadata[field.key] || ""}
459
+ onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
460
+ disabled={field.readonly}
461
+ required={field.required}
462
+ className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
463
+ <option value="">Select Material...</option>
464
+ {materialTypes.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>))}
465
+ </select>
466
+ ) : field.type === "select" ? (
467
+ <select value={entry.metadata[field.key] || ""}
468
+ onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value } })}
469
+ disabled={field.readonly}
470
+ required={field.required}
471
+ className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
472
+ <option value="">Select...</option>
473
+ {field.options?.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}
474
+ </select>
475
+ ) : field.type === "multi_select" as any ? (
457
476
  <AsyncSearchableSelect
458
- defaultValue={entry.metadata[field.key] ? { cheque_number: entry.metadata[field.key] } : undefined}
459
- onChange={(val, selected: any) => {
460
- const leafObj = Array.isArray(selected) ? selected[0] : selected;
461
- const newMetadata = { ...entry.metadata, [field.key]: val, cheque_leaf_id: leafObj?.id || "" };
462
- if (leafObj?.cheque_book?.bank_account) {
463
- const ba = leafObj.cheque_book.bank_account;
464
- newMetadata.bank_account_id = ba.id;
465
- newMetadata._display_bank_name = ba.bank_name;
466
- newMetadata._display_branch = ba.branch;
467
- } else {
468
- delete newMetadata.bank_account_id;
469
- delete newMetadata._display_bank_name;
470
- delete newMetadata._display_branch;
471
- }
472
- onUpdate({ metadata: newMetadata });
473
- }}
474
- loadOptions={async (search, page) => {
475
- if (!fetchChequeLeaves) return [];
476
- const res = await fetchChequeLeaves(search, page);
477
- return res.is_success && res.result ? res.result : [];
478
- }}
479
- option={{
480
- label: "cheque_number",
481
- value: "cheque_number",
482
- renderOption: (item: any) => (
483
- <div className="flex flex-col">
484
- <span>{item.cheque_number}</span>
485
- {item.cheque_book?.bank_account?.bank_name && (
486
- <span className="text-[11px] text-foreground-subtle mt-0.5">
487
- {item.cheque_book.bank_account.bank_name} {item.cheque_book.bank_account.branch ? `— ${item.cheque_book.bank_account.branch}` : ""}
488
- </span>
489
- )}
490
- </div>
491
- )
477
+ multiple
478
+ option={{ label: 'label', value: 'value' }}
479
+ defaultValue={
480
+ Array.isArray(entry.metadata[field.key])
481
+ ? (field.options || []).filter(o => (entry.metadata[field.key] as any).includes(o.value))
482
+ : []
483
+ }
484
+ onChange={(val, selectedObjs: any) => {
485
+ const sum = Array.isArray(selectedObjs)
486
+ ? selectedObjs.reduce((acc, curr) => acc + (curr.unapplied_amount || 0), 0)
487
+ : 0;
488
+
489
+ onUpdate({
490
+ amount: sum,
491
+ metadata: { ...entry.metadata, [field.key]: val as any }
492
+ });
492
493
  }}
493
- placeholder="Search cheque number..."
494
494
  disabled={field.readonly}
495
495
  required={field.required}
496
+ placeholder="Select..."
497
+ loadOptions={async () => field.options || []}
496
498
  />
497
- {entry.metadata._display_bank_name && (
498
- <div className="text-[11px] text-foreground-subtle mt-1 flex items-center gap-1.5 font-medium px-1">
499
- <Building size={12} className="text-foreground-disabled" />
500
- {entry.metadata._display_bank_name} {entry.metadata._display_branch ? `— ${entry.metadata._display_branch}` : ""}
501
- </div>
502
- )}
503
- </>
504
- ) : (
505
- <input type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
506
- {...(field.type === "number" ? { step: "any" } : {})}
507
- value={entry.metadata[field.key] || ""}
508
- onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value } })}
509
- readOnly={field.readonly}
510
- required={field.required}
511
- className={cn("w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", field.readonly && "opacity-60 bg-surface-0 cursor-not-allowed border-border")} />
512
- )}
513
- </div>
514
- )})}
499
+ ) : field.key === "cheque_number" && fetchChequeLeaves ? (
500
+ <>
501
+ <AsyncSearchableSelect
502
+ defaultValue={entry.metadata[field.key] ? { cheque_number: entry.metadata[field.key] } : undefined}
503
+ onChange={(val, selected: any) => {
504
+ const leafObj = Array.isArray(selected) ? selected[0] : selected;
505
+ const newMetadata = { ...entry.metadata, [field.key]: val, cheque_leaf_id: leafObj?.id || "" };
506
+ if (leafObj?.cheque_book?.bank_account) {
507
+ const ba = leafObj.cheque_book.bank_account;
508
+ newMetadata.bank_account_id = ba.id;
509
+ newMetadata._display_bank_name = ba.bank_name;
510
+ newMetadata._display_branch = ba.branch;
511
+ } else {
512
+ delete newMetadata.bank_account_id;
513
+ delete newMetadata._display_bank_name;
514
+ delete newMetadata._display_branch;
515
+ }
516
+ onUpdate({ metadata: newMetadata });
517
+ }}
518
+ loadOptions={async (search, page) => {
519
+ if (!fetchChequeLeaves) return [];
520
+ const res = await fetchChequeLeaves(search, page);
521
+ return res.is_success && res.result ? res.result : [];
522
+ }}
523
+ option={{
524
+ label: "cheque_number",
525
+ value: "cheque_number",
526
+ renderOption: (item: any) => (
527
+ <div className="flex flex-col">
528
+ <span>{item.cheque_number}</span>
529
+ {item.cheque_book?.bank_account?.bank_name && (
530
+ <span className="text-[11px] text-foreground-subtle mt-0.5">
531
+ {item.cheque_book.bank_account.bank_name} {item.cheque_book.bank_account.branch ? `— ${item.cheque_book.bank_account.branch}` : ""}
532
+ </span>
533
+ )}
534
+ </div>
535
+ )
536
+ }}
537
+ placeholder="Search cheque number..."
538
+ disabled={field.readonly}
539
+ required={field.required}
540
+ />
541
+ {entry.metadata._display_bank_name && (
542
+ <div className="text-[11px] text-foreground-subtle mt-1 flex items-center gap-1.5 font-medium px-1">
543
+ <Building size={12} className="text-foreground-disabled" />
544
+ {entry.metadata._display_bank_name} {entry.metadata._display_branch ? `— ${entry.metadata._display_branch}` : ""}
545
+ </div>
546
+ )}
547
+ </>
548
+ ) : (
549
+ <input type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
550
+ {...(field.type === "number" ? { step: "any" } : {})}
551
+ value={entry.metadata[field.key] || ""}
552
+ onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value } })}
553
+ readOnly={field.readonly}
554
+ required={field.required}
555
+ className={cn("w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", field.readonly && "opacity-60 bg-surface-0 cursor-not-allowed border-border")} />
556
+ )}
557
+ </div>
558
+ )
559
+ })}
515
560
  </div>
516
561
  )}
517
562
  </div>