@apptimate/ui 7.2.0 → 7.4.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.
@@ -31,7 +31,7 @@ export interface ItemFormData {
31
31
  min_stock_level: string; max_stock_level: string;
32
32
  reorder_point: string; reorder_qty: string; lead_time_days: string;
33
33
  is_available_purchase: boolean; is_available_sell: boolean; is_available_ecommerce: boolean;
34
- has_stocks: boolean; is_taxable: boolean; is_returnable: boolean; is_discountable: boolean;
34
+ has_stocks: boolean; is_taxable: boolean; is_returnable: boolean; is_discountable: boolean; allow_phantom_bom: boolean;
35
35
  weight: string; weight_unit: string; dimensions: string;
36
36
  attributes: { name: string; values: string[] }[];
37
37
  // Page 3: Variants
@@ -56,7 +56,7 @@ export const emptyFormData: ItemFormData = {
56
56
  min_stock_level: "", max_stock_level: "",
57
57
  reorder_point: "", reorder_qty: "", lead_time_days: "",
58
58
  is_available_purchase: true, is_available_sell: true, is_available_ecommerce: false,
59
- has_stocks: true, is_taxable: true, is_returnable: true, is_discountable: true,
59
+ has_stocks: true, is_taxable: true, is_returnable: true, is_discountable: true, allow_phantom_bom: false,
60
60
  weight: "", weight_unit: "", dimensions: "",
61
61
  attributes: [{ name: "", values: [] }],
62
62
  variants: [],
@@ -314,8 +314,8 @@ export default function ItemFormWizard({
314
314
  setFormData(p => ({ ...p, attributes: newAttrs }));
315
315
  }}
316
316
  className={`p-2.5 rounded-lg transition-colors mt-0.5 ${isEditing
317
- ? "text-gray-300 cursor-not-allowed"
318
- : "text-gray-400 hover:text-danger-alt hover:bg-danger-50"
317
+ ? "text-gray-300 cursor-not-allowed"
318
+ : "text-gray-400 hover:text-danger-alt hover:bg-danger-50"
319
319
  }`}
320
320
  >
321
321
  <Trash size={16} />
@@ -336,6 +336,7 @@ export default function ItemFormWizard({
336
336
  label={<span className="flex items-center gap-1.5">Available for Purchase <HintIcon text="Can be purchased from suppliers" /></span>}
337
337
  checked={formData.is_available_purchase}
338
338
  onChange={(e: any) => setFormData((p) => ({ ...p, is_available_purchase: e.target.checked }))}
339
+ disabled={formData.allow_phantom_bom}
339
340
  />
340
341
  <Checkbox
341
342
  label={<span className="flex items-center gap-1.5">Available for Sale <HintIcon text="Can be sold to customers" /></span>}
@@ -351,66 +352,88 @@ export default function ItemFormWizard({
351
352
  label={<span className="flex items-center gap-1.5">Track Stock <HintIcon text="Monitor inventory levels" /></span>}
352
353
  checked={formData.has_stocks}
353
354
  onChange={(e: any) => setFormData((p) => ({ ...p, has_stocks: e.target.checked }))}
354
- disabled={isEditing && formData.can_edit_variants === false}
355
+ disabled={formData.allow_phantom_bom || (isEditing && formData.can_edit_variants === false)}
355
356
  />
356
357
  <Checkbox
357
358
  label={<span className="flex items-center gap-1.5">Taxable <HintIcon text="Subject to taxes" /></span>}
358
359
  checked={formData.is_taxable}
359
360
  onChange={(e: any) => setFormData((p) => ({ ...p, is_taxable: e.target.checked }))}
361
+ disabled={formData.allow_phantom_bom}
360
362
  />
361
363
  <Checkbox
362
364
  label={<span className="flex items-center gap-1.5">Returnable <HintIcon text="Eligible for returns" /></span>}
363
365
  checked={formData.is_returnable}
364
366
  onChange={(e: any) => setFormData((p) => ({ ...p, is_returnable: e.target.checked }))}
367
+ disabled={formData.allow_phantom_bom}
365
368
  />
366
369
  <Checkbox
367
370
  label={<span className="flex items-center gap-1.5">Discountable <HintIcon text="Eligible for discounts" /></span>}
368
371
  checked={formData.is_discountable}
369
372
  onChange={(e: any) => setFormData((p) => ({ ...p, is_discountable: e.target.checked }))}
373
+ disabled={formData.allow_phantom_bom}
374
+ />
375
+ <Checkbox
376
+ label={<span className="flex items-center gap-1.5">Allow Phantom BOM <HintIcon text="Can be used as a finished good in a Phantom BOM" /></span>}
377
+ checked={formData.allow_phantom_bom}
378
+ onChange={(e: any) => setFormData((p) => {
379
+ const checked = e.target.checked;
380
+ if (checked) {
381
+ return {
382
+ ...p,
383
+ allow_phantom_bom: true,
384
+ is_available_purchase: false,
385
+ has_stocks: false,
386
+ is_taxable: false,
387
+ is_returnable: false,
388
+ is_discountable: false,
389
+ };
390
+ }
391
+ return { ...p, allow_phantom_bom: false };
392
+ })}
370
393
  />
371
394
  </div>
372
395
  </div>
373
396
 
374
- <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
375
- <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Tracking Options</p>
376
- <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
377
- <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
378
- <input
379
- type="radio"
380
- name="tracking_type"
381
- className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
382
- checked={formData.tracking_type === 'none'}
383
- onChange={() => setFormData(p => ({ ...p, tracking_type: 'none' }))}
384
- disabled={isEditing}
385
- />
386
- <span>None</span>
387
- </label>
388
- <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
389
- <input
390
- type="radio"
391
- name="tracking_type"
392
- className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
393
- checked={formData.tracking_type === 'batch'}
394
- onChange={() => setFormData(p => ({ ...p, tracking_type: 'batch' }))}
395
- disabled={isEditing}
396
- />
397
- <span className="flex items-center gap-1.5">Batches <HintIcon text="Track items by manufacturing batch or lot" /></span>
398
- </label>
399
- <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
400
- <input
401
- type="radio"
402
- name="tracking_type"
403
- className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
404
- checked={formData.tracking_type === 'serial'}
405
- onChange={() => setFormData(p => ({ ...p, tracking_type: 'serial' }))}
406
- disabled={isEditing}
407
- />
408
- <span className="flex items-center gap-1.5">Serial Numbers <HintIcon text="Track each individual unit with a unique serial number" /></span>
409
- </label>
410
- </div>
411
-
397
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
398
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Tracking Options</p>
399
+ <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
400
+ <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
401
+ <input
402
+ type="radio"
403
+ name="tracking_type"
404
+ className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
405
+ checked={formData.tracking_type === 'none'}
406
+ onChange={() => setFormData(p => ({ ...p, tracking_type: 'none' }))}
407
+ disabled={isEditing}
408
+ />
409
+ <span>None</span>
410
+ </label>
411
+ <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
412
+ <input
413
+ type="radio"
414
+ name="tracking_type"
415
+ className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
416
+ checked={formData.tracking_type === 'batch'}
417
+ onChange={() => setFormData(p => ({ ...p, tracking_type: 'batch' }))}
418
+ disabled={isEditing}
419
+ />
420
+ <span className="flex items-center gap-1.5">Batches <HintIcon text="Track items by manufacturing batch or lot" /></span>
421
+ </label>
422
+ <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
423
+ <input
424
+ type="radio"
425
+ name="tracking_type"
426
+ className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
427
+ checked={formData.tracking_type === 'serial'}
428
+ onChange={() => setFormData(p => ({ ...p, tracking_type: 'serial' }))}
429
+ disabled={isEditing}
430
+ />
431
+ <span className="flex items-center gap-1.5">Serial Numbers <HintIcon text="Track each individual unit with a unique serial number" /></span>
432
+ </label>
412
433
  </div>
413
434
 
435
+ </div>
436
+
414
437
  {formData.has_stocks && formData.tracking_type !== 'serial' && (
415
438
  <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
416
439
  <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Valuation Method (COGS)</p>
@@ -507,7 +530,10 @@ export default function ItemFormWizard({
507
530
 
508
531
  const generateCombinations = useCallback(() => {
509
532
  const validAttributes = formData.attributes.filter(a => a.name && a.values.length > 0);
510
- if (validAttributes.length === 0) return;
533
+ if (validAttributes.length === 0) {
534
+ setFormData(p => p.variants.length > 0 ? { ...p, variants: [] } : p);
535
+ return;
536
+ }
511
537
 
512
538
  let combinations: any[] = [{}];
513
539
  validAttributes.forEach((attr) => {
@@ -523,28 +549,60 @@ export default function ItemFormWizard({
523
549
  combinations = newCombinations;
524
550
  });
525
551
 
526
- const newVariants: VariantRow[] = combinations.map((combo) => {
527
- const variantName = Object.values(combo).join(" - ");
528
- return {
529
- variant_name: variantName,
530
- sku: "",
531
- barcode: "",
532
- sale_price: formData.sale_price || "0",
533
- cost_price: formData.cost_price || "0",
534
- min_sales_price: formData.min_sales_price || "0",
535
- attribute_values: combo,
536
- };
552
+ setFormData(p => {
553
+ const newVariants: VariantRow[] = combinations.map((combo) => {
554
+ const variantName = Object.values(combo).join(" - ");
555
+
556
+ const existingVariant = p.variants.find((v) => {
557
+ // Primary match: exact attribute_values match
558
+ if (v.attribute_values && Object.keys(v.attribute_values).length > 0) {
559
+ const comboKeys = Object.keys(combo);
560
+ const vKeys = Object.keys(v.attribute_values);
561
+ if (comboKeys.length === vKeys.length && comboKeys.every(k => v.attribute_values[k] === combo[k])) {
562
+ return true;
563
+ }
564
+ }
565
+ // Fallback: match by variant_name
566
+ if (v.variant_name && v.variant_name === variantName) return true;
567
+ return false;
568
+ });
569
+
570
+ if (existingVariant) {
571
+ return { ...existingVariant, variant_name: variantName, attribute_values: combo };
572
+ }
573
+
574
+ return {
575
+ variant_name: variantName,
576
+ sku: "",
577
+ barcode: "",
578
+ sale_price: p.sale_price || "0",
579
+ cost_price: p.cost_price || "0",
580
+ min_sales_price: p.min_sales_price || "0",
581
+ attribute_values: combo,
582
+ };
583
+ });
584
+
585
+ if (p.variants.length !== newVariants.length) {
586
+ return { ...p, variants: newVariants };
587
+ }
588
+
589
+ const hasChanges = newVariants.some((v, idx) => v !== p.variants[idx]);
590
+ if (hasChanges) {
591
+ return { ...p, variants: newVariants };
592
+ }
593
+
594
+ return p;
537
595
  });
596
+ }, [formData.attributes, setFormData]);
538
597
 
539
- setFormData(p => ({ ...p, variants: newVariants }));
540
- }, [formData.attributes, formData.sale_price, formData.cost_price, setFormData]);
598
+ const attributesStr = JSON.stringify(formData.attributes);
541
599
 
542
- // Auto-generate variants on step 2 if empty
600
+ // Auto-generate variants on step 2
543
601
  React.useEffect(() => {
544
- if (step === 2 && formData.has_variants && formData.variants.length === 0) {
602
+ if (step === 2 && formData.has_variants) {
545
603
  generateCombinations();
546
604
  }
547
- }, [step, formData.has_variants, formData.variants.length, generateCombinations]);
605
+ }, [step, formData.has_variants, attributesStr, generateCombinations]);
548
606
 
549
607
  const renderPage3 = () => (
550
608
  <div className="space-y-5 animate-fade-in">