@apptimate/ui 1.9.0 → 2.1.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": "1.9.0",
3
+ "version": "2.1.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -8,12 +8,39 @@ export interface DateLabelProps {
8
8
  export function DateLabel({ date, className = '' }: DateLabelProps) {
9
9
  if (!date) return <span className="text-gray-300">-</span>;
10
10
 
11
- const d = new Date(date);
12
-
13
- // Format to "2026-05-07 4:35PM"
11
+ let isDateOnly = false;
12
+ let parsedDate: string | Date = date;
13
+
14
+ if (typeof date === 'string') {
15
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
16
+ isDateOnly = true;
17
+ } else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(date)) {
18
+ parsedDate = date.replace(' ', 'T') + 'Z';
19
+ }
20
+ }
21
+
22
+ let d: Date;
23
+ if (isDateOnly && typeof date === 'string') {
24
+ const [y, m, day] = date.split('-');
25
+ d = new Date(parseInt(y, 10), parseInt(m, 10) - 1, parseInt(day, 10));
26
+ } else {
27
+ d = new Date(parsedDate);
28
+ }
29
+
30
+ if (isNaN(d.getTime())) return <span className="text-gray-300">-</span>;
31
+
14
32
  const year = d.getFullYear();
15
33
  const month = String(d.getMonth() + 1).padStart(2, '0');
16
34
  const day = String(d.getDate()).padStart(2, '0');
35
+
36
+ if (isDateOnly) {
37
+ return (
38
+ <div className={`flex flex-col ${className}`}>
39
+ <span className="text-gray-600 font-medium whitespace-nowrap">{year}-{month}-{day}</span>
40
+ </div>
41
+ );
42
+ }
43
+
17
44
  let hours = d.getHours();
18
45
  const minutes = String(d.getMinutes()).padStart(2, '0');
19
46
  const ampm = hours >= 12 ? 'PM' : 'AM';
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Search } from 'lucide-react';
2
+ import { Search, X } from 'lucide-react';
3
3
  import { Dropdown } from './Dropdown';
4
4
  import { Input } from './Input';
5
5
  import { Button } from './Button';
@@ -26,16 +26,33 @@ export const DesktopSearchPopover = ({
26
26
  align="right"
27
27
  contentClassName="w-[300px] p-2"
28
28
  trigger={
29
- <Button
30
- variant="soft"
31
- color="default"
32
- className={cn("!p-0 text-gray-500", triggerClassName)}
33
- icon={<Search size={18} strokeWidth={2} />}
34
- ariaLabel="Search"
35
- />
29
+ searchValue ? (
30
+ <div className="flex items-center gap-1.5 bg-primary-50 hover:bg-primary-100 transition-colors pl-3 pr-1.5 py-1.5 rounded-full cursor-pointer border border-primary-100">
31
+ <Search size={14} className="text-primary-500" />
32
+ <span className="text-xs font-bold text-primary-700 max-w-[120px] truncate">{searchValue}</span>
33
+ <div
34
+ className="p-1 hover:bg-primary-200 rounded-full text-primary-600 transition-colors"
35
+ onClick={(e) => {
36
+ e.stopPropagation();
37
+ onSearchChange("");
38
+ }}
39
+ title="Clear search"
40
+ >
41
+ <X size={14} />
42
+ </div>
43
+ </div>
44
+ ) : (
45
+ <Button
46
+ variant="soft"
47
+ color="default"
48
+ className={cn("!p-0 text-gray-500", triggerClassName)}
49
+ icon={<Search size={18} strokeWidth={2} />}
50
+ ariaLabel="Search"
51
+ />
52
+ )
36
53
  }
37
54
  >
38
- <div className="p-1">
55
+ <div className="p-1" onClick={(e) => e.stopPropagation()}>
39
56
  <Input
40
57
  autoFocus
41
58
  placeholder={placeholder}
@@ -135,7 +135,7 @@ export function DashboardLayout({
135
135
  size: 22,
136
136
  className: isActive ? "stroke-[2.5]" : "stroke-[2]"
137
137
  })}
138
- <span className={`text-[11px] ${isActive ? "font-bold" : "font-medium"}`}>{menu.label}</span>
138
+ <span className={`text-[11px] text-center leading-tight px-1 ${isActive ? "font-bold" : "font-medium"}`}>{menu.label}</span>
139
139
  </div>
140
140
  );
141
141
  })}
@@ -9,8 +9,9 @@ import { CreditCard, Banknote, Building, Trash2, Loader2, Split, Check, AlertCir
9
9
  export interface PaymentModeField {
10
10
  key: string;
11
11
  label: string;
12
- type: "text" | "number" | "date" | "bank_account_select";
12
+ type: "text" | "number" | "date" | "bank_account_select" | "material_type_select";
13
13
  required: boolean;
14
+ readonly?: boolean;
14
15
  }
15
16
 
16
17
  export interface PaymentMode {
@@ -56,11 +57,15 @@ export interface PaymentSectionProps {
56
57
  fetchPaymentModes: () => Promise<{ is_success: boolean; result?: any }>;
57
58
  /** Fetcher for bank accounts */
58
59
  fetchBankAccounts: () => Promise<{ is_success: boolean; result?: any }>;
60
+ /** Optional array of material types for the material_type_select field */
61
+ materialTypes?: any[];
62
+ /** Special mode codes that are allowed (e.g. 'credit-gold') */
63
+ allowedSpecialModes?: string[];
59
64
  }
60
65
 
61
66
  /* ── Component ── */
62
67
 
63
- export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts }: PaymentSectionProps) {
68
+ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [] }: PaymentSectionProps) {
64
69
  const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
65
70
  const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
66
71
  const [isLoading, setIsLoading] = useState(false);
@@ -72,7 +77,9 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
72
77
  setIsLoading(true);
73
78
  try {
74
79
  const [modesRes, bankRes] = await Promise.all([fetchPaymentModes(), fetchBankAccounts()]);
75
- const modes: PaymentMode[] = modesRes.is_success ? (modesRes.result || []) : [];
80
+ const modesRaw: PaymentMode[] = modesRes.is_success ? (modesRes.result || []) : [];
81
+ const SPECIAL_MODES = ["credit-gold"];
82
+ const modes = modesRaw.filter((m) => !SPECIAL_MODES.includes(m.code) || allowedSpecialModes.includes(m.code));
76
83
  setPaymentModes(modes);
77
84
  if (bankRes.is_success) setBankAccounts(bankRes.result || []);
78
85
 
@@ -92,16 +99,18 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
92
99
  }, []);
93
100
 
94
101
  // ── Calculations ──
95
- const totalPaid = useMemo(() => payments.reduce((s, p) => s + p.amount, 0), [payments]);
102
+ const isCreditPayment = useCallback((payment: PaymentEntry) => payment.mode_code === "credit", []);
103
+ const totalPaid = useMemo(() => payments.reduce((s, p) => s + (isCreditPayment(p) ? 0 : p.amount), 0), [payments, isCreditPayment]);
104
+ const creditAmount = useMemo(() => payments.reduce((s, p) => s + (isCreditPayment(p) ? p.amount : 0), 0), [payments, isCreditPayment]);
96
105
  const balance = useMemo(() => totalAmount - totalPaid, [totalAmount, totalPaid]);
97
106
  const formatCurrency = (v: number) => v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
98
107
 
99
- // ── Mode icon ──
100
108
  const getModeIcon = useCallback((code: string, size = 14) => {
101
109
  switch (code) {
102
110
  case "cash": return <Banknote size={size} />;
103
111
  case "card": return <CreditCard size={size} />;
104
112
  case "bank_transfer": return <Building size={size} />;
113
+ case "credit-gold": return <AlertCircle size={size} />; // Fallback, better to use something else if available
105
114
  default: return <CreditCard size={size} />;
106
115
  }
107
116
  }, []);
@@ -118,8 +127,34 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
118
127
  const updatePayment = useCallback((index: number, updates: Partial<PaymentEntry>) => {
119
128
  const next = [...payments];
120
129
  next[index] = { ...next[index], ...updates };
130
+
131
+ if (
132
+ selectionMode === "split" &&
133
+ Object.prototype.hasOwnProperty.call(updates, "amount") &&
134
+ !isCreditPayment(next[index]) &&
135
+ next.length > 1
136
+ ) {
137
+ const targetIndex = next.findIndex((payment, paymentIndex) =>
138
+ paymentIndex !== index &&
139
+ !isCreditPayment(payment) &&
140
+ Number(payment.amount || 0) <= 0
141
+ );
142
+
143
+ if (targetIndex >= 0) {
144
+ const paidExceptTarget = next.reduce((sum, payment, paymentIndex) => {
145
+ if (paymentIndex === targetIndex || isCreditPayment(payment)) return sum;
146
+ return sum + Number(payment.amount || 0);
147
+ }, 0);
148
+
149
+ next[targetIndex] = {
150
+ ...next[targetIndex],
151
+ amount: Math.max(0, Math.round((totalAmount - paidExceptTarget) * 100) / 100),
152
+ };
153
+ }
154
+ }
155
+
121
156
  onPaymentsChange(next);
122
- }, [payments, onPaymentsChange]);
157
+ }, [payments, selectionMode, totalAmount, isCreditPayment, onPaymentsChange]);
123
158
 
124
159
  // ── Auto-sync single mode amount when totalAmount changes ──
125
160
  useEffect(() => {
@@ -191,7 +226,7 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
191
226
  {/* ── Single Mode: Entry with fields (no delete) ── */}
192
227
  {selectionMode === "single" && payments.length === 1 && (
193
228
  <PaymentEntryRow entry={payments[0]} mode={paymentModes.find((m) => m.id === payments[0].mode_id)}
194
- bankAccounts={bankAccounts} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => {}} showRemove={false} />
229
+ bankAccounts={bankAccounts} materialTypes={materialTypes} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => {}} showRemove={false} />
195
230
  )}
196
231
 
197
232
  {/* ── Split Mode: Multi-entry UI ── */}
@@ -200,14 +235,18 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
200
235
  {payments.map((entry, idx) => {
201
236
  const mode = paymentModes.find((m) => m.id === entry.mode_id);
202
237
  return (
203
- <PaymentEntryRow key={idx} entry={entry} mode={mode} bankAccounts={bankAccounts}
238
+ <PaymentEntryRow key={idx} entry={entry} mode={mode} bankAccounts={bankAccounts} materialTypes={materialTypes}
204
239
  onUpdate={(updates) => updatePayment(idx, updates)} onRemove={() => removePayment(idx)} />
205
240
  );
206
241
  })}
207
242
  {/* Add split mode buttons */}
208
243
  <div className="flex flex-wrap gap-1.5 pt-1">
209
244
  {paymentModes
210
- .filter((m) => m.is_active && !payments.some(p => p.mode_id === m.id))
245
+ .filter((m) => {
246
+ if (!m.is_active) return false;
247
+ const canBeMultiple = ["card", "bank_transfer", "cheque"].includes(m.code);
248
+ return canBeMultiple || !payments.some(p => p.mode_id === m.id);
249
+ })
211
250
  .map((mode) => (
212
251
  <button key={mode.id} onClick={() => addPayment(mode)}
213
252
  className="inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold rounded-[8px] bg-surface-0 border border-border-subtle text-foreground-subtle hover:border-primary/40 hover:text-primary transition-all">
@@ -225,8 +264,11 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
225
264
  <TotalRow label="Amount to Pay" value={formatCurrency(totalAmount)} bold />
226
265
  <div className="border-t border-border-subtle my-2" />
227
266
  <TotalRow label="Total Paid" value={formatCurrency(totalPaid)} />
267
+ {creditAmount > 0.01 && (
268
+ <TotalRow label="Credit Amount" value={formatCurrency(creditAmount)} className="text-amber-600" />
269
+ )}
228
270
  <TotalRow
229
- label={balance > 0.01 ? "Balance Due" : balance < -0.01 ? "Change Due" : "Settled"}
271
+ label={balance > 0.01 ? "Outstanding Balance" : balance < -0.01 ? "Change Due" : "Settled"}
230
272
  value={formatCurrency(Math.abs(balance))}
231
273
  className={balance > 0.01 ? "text-danger-alt" : "text-green-600"}
232
274
  bold
@@ -236,7 +278,7 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
236
278
  <AlertCircle size={12} /><span>Change: {formatCurrency(Math.abs(balance))}</span>
237
279
  </div>
238
280
  )}
239
- {Math.abs(balance) < 0.01 && (
281
+ {Math.abs(balance) < 0.01 && creditAmount <= 0.01 && (
240
282
  <div className="flex items-center gap-1.5 mt-1 text-[11px] text-green-600 font-medium">
241
283
  <Check size={12} /><span>Fully paid</span>
242
284
  </div>
@@ -264,12 +306,13 @@ interface PaymentEntryRowProps {
264
306
  entry: PaymentEntry;
265
307
  mode?: PaymentMode;
266
308
  bankAccounts: BankAccount[];
309
+ materialTypes?: any[];
267
310
  onUpdate: (updates: Partial<PaymentEntry>) => void;
268
311
  onRemove: () => void;
269
312
  showRemove?: boolean;
270
313
  }
271
314
 
272
- function PaymentEntryRow({ entry, mode, bankAccounts, onUpdate, onRemove, showRemove = true }: PaymentEntryRowProps) {
315
+ function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpdate, onRemove, showRemove = true }: PaymentEntryRowProps) {
273
316
  const fields = mode?.field_config?.fields || [];
274
317
 
275
318
  const getModeIcon = () => {
@@ -277,6 +320,7 @@ function PaymentEntryRow({ entry, mode, bankAccounts, onUpdate, onRemove, showRe
277
320
  case "cash": return <Banknote size={13} />;
278
321
  case "card": return <CreditCard size={13} />;
279
322
  case "bank_transfer": return <Building size={13} />;
323
+ case "credit-gold": return <AlertCircle size={13} />;
280
324
  default: return <CreditCard size={13} />;
281
325
  }
282
326
  };
@@ -317,15 +361,26 @@ function PaymentEntryRow({ entry, mode, bankAccounts, onUpdate, onRemove, showRe
317
361
  {field.type === "bank_account_select" ? (
318
362
  <select value={entry.metadata[field.key] || ""}
319
363
  onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
320
- 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">
364
+ disabled={field.readonly}
365
+ 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">
321
366
  <option value="">Select...</option>
322
367
  {bankAccounts.map((b) => (<option key={b.id} value={b.id}>{b.bank_name} - {b.account_number}</option>))}
323
368
  </select>
369
+ ) : field.type === "material_type_select" ? (
370
+ <select value={entry.metadata[field.key] || ""}
371
+ onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
372
+ disabled={field.readonly}
373
+ 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">
374
+ <option value="">Select Material...</option>
375
+ {materialTypes.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>))}
376
+ </select>
324
377
  ) : (
325
378
  <input type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
379
+ {...(field.type === "number" ? { step: "any" } : {})}
326
380
  value={entry.metadata[field.key] || ""}
327
381
  onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value } })}
328
- 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 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
382
+ readOnly={field.readonly}
383
+ 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")} />
329
384
  )}
330
385
  </div>
331
386
  ))}