SpoolBuddyInventoryPage.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { useState, useMemo } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { api, type InventorySpool } from '../../api/client';
  5. function formatDate(dateStr: string | null): string {
  6. if (!dateStr) return '-';
  7. try {
  8. const d = new Date(dateStr);
  9. return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  10. } catch {
  11. return '-';
  12. }
  13. }
  14. function SpoolCard({ spool }: { spool: InventorySpool }) {
  15. const { t } = useTranslation();
  16. const [expanded, setExpanded] = useState(false);
  17. const remaining = Math.max(0, spool.label_weight - spool.weight_used);
  18. const remainingPct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
  19. const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
  20. return (
  21. <button
  22. className="w-full bg-zinc-800 rounded-lg p-3 text-left transition-colors hover:bg-zinc-750 active:bg-zinc-700"
  23. onClick={() => setExpanded(!expanded)}
  24. >
  25. <div className="flex items-center gap-3">
  26. {/* Color swatch */}
  27. <div
  28. className="w-8 h-8 rounded-full border border-zinc-700 shrink-0"
  29. style={{ backgroundColor: colorHex }}
  30. />
  31. {/* Info */}
  32. <div className="flex-1 min-w-0">
  33. <div className="flex items-center gap-2">
  34. <span className="text-sm font-medium text-zinc-100 truncate">
  35. {spool.material}
  36. {spool.color_name && <span className="text-zinc-400 ml-1">- {spool.color_name}</span>}
  37. </span>
  38. </div>
  39. {spool.brand && (
  40. <span className="text-xs text-zinc-500 truncate block">{spool.brand}</span>
  41. )}
  42. </div>
  43. {/* Weight */}
  44. <div className="text-right shrink-0">
  45. <div className="text-sm text-zinc-300">{Math.round(remaining)}g</div>
  46. <div className="text-xs text-zinc-500">{t('spoolbuddy.spool.remaining', 'Remaining')}</div>
  47. </div>
  48. </div>
  49. {/* Fill bar */}
  50. <div className="w-full h-1 bg-zinc-700 rounded-full overflow-hidden mt-2">
  51. <div
  52. className="h-full rounded-full transition-all"
  53. style={{
  54. width: `${Math.min(100, remainingPct)}%`,
  55. backgroundColor: remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444',
  56. }}
  57. />
  58. </div>
  59. {/* Expanded details */}
  60. {expanded && (
  61. <div className="mt-3 pt-3 border-t border-zinc-700 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
  62. <div className="flex justify-between">
  63. <span className="text-zinc-500">{t('spoolbuddy.spool.labelWeight', 'Label')}</span>
  64. <span className="text-zinc-400">{spool.label_weight}g</span>
  65. </div>
  66. <div className="flex justify-between">
  67. <span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
  68. <span className="text-zinc-400">{spool.core_weight}g</span>
  69. </div>
  70. <div className="flex justify-between">
  71. <span className="text-zinc-500">{t('spoolbuddy.spool.material', 'Material')}</span>
  72. <span className="text-zinc-400">{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}</span>
  73. </div>
  74. <div className="flex justify-between">
  75. <span className="text-zinc-500">{t('spoolbuddy.spool.lastUsed', 'Last used')}</span>
  76. <span className="text-zinc-400">{formatDate(spool.last_used)}</span>
  77. </div>
  78. {spool.tag_uid && (
  79. <div className="col-span-2 flex justify-between">
  80. <span className="text-zinc-500">Tag</span>
  81. <span className="text-zinc-400 font-mono">{spool.tag_uid}</span>
  82. </div>
  83. )}
  84. </div>
  85. )}
  86. </button>
  87. );
  88. }
  89. export function SpoolBuddyInventoryPage() {
  90. const { t } = useTranslation();
  91. const [search, setSearch] = useState('');
  92. const { data: spools = [], isLoading } = useQuery({
  93. queryKey: ['inventory-spools'],
  94. queryFn: () => api.getSpools(false),
  95. });
  96. const filtered = useMemo(() => {
  97. if (!search.trim()) return spools;
  98. const q = search.toLowerCase();
  99. return spools.filter((s) =>
  100. s.material.toLowerCase().includes(q) ||
  101. s.brand?.toLowerCase().includes(q) ||
  102. s.color_name?.toLowerCase().includes(q)
  103. );
  104. }, [spools, search]);
  105. return (
  106. <div className="h-full flex flex-col p-4">
  107. {/* Header */}
  108. <div className="flex items-center justify-between mb-4">
  109. <h1 className="text-lg font-semibold text-zinc-100">
  110. {t('spoolbuddy.nav.inventory', 'Inventory')}
  111. </h1>
  112. <span className="text-sm text-zinc-500">{spools.length} {t('spoolbuddy.inventory.spools', 'spools')}</span>
  113. </div>
  114. {/* Search */}
  115. <div className="relative mb-4">
  116. <svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  117. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
  118. </svg>
  119. <input
  120. type="text"
  121. value={search}
  122. onChange={(e) => setSearch(e.target.value)}
  123. placeholder={t('spoolbuddy.inventory.search', 'Search spools...')}
  124. className="w-full pl-10 pr-4 py-2.5 bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 min-h-[44px]"
  125. />
  126. </div>
  127. {/* Spool list */}
  128. <div className="flex-1 min-h-0 overflow-y-auto space-y-2">
  129. {isLoading ? (
  130. <div className="flex items-center justify-center h-32">
  131. <span className="text-zinc-500">{t('common.loading', 'Loading...')}</span>
  132. </div>
  133. ) : filtered.length === 0 ? (
  134. <div className="flex flex-col items-center justify-center h-32 text-zinc-500">
  135. <svg className="w-10 h-10 mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  136. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
  137. </svg>
  138. <p className="text-sm">{search ? t('spoolbuddy.inventory.noResults', 'No matching spools') : t('spoolbuddy.inventory.empty', 'No spools in inventory')}</p>
  139. </div>
  140. ) : (
  141. filtered.map((spool) => <SpoolCard key={spool.id} spool={spool} />)
  142. )}
  143. </div>
  144. </div>
  145. );
  146. }