MaterialNumberStats.tsx 3.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
  1. import { useQuery } from '@tanstack/react-query';
  2. import { useTranslation } from 'react-i18next';
  3. import { Loader2 } from 'lucide-react';
  4. import { api } from '../api/client';
  5. // Consumption, cost and stock grouped by the internal material number
  6. // (#2870) — the identifier the business actually purchases and costs by,
  7. // unlike brand+material+colour. Data comes from the dedicated aggregate
  8. // endpoint so archived spools' recorded usage still counts.
  9. interface MaterialNumberStatsProps {
  10. currency: string;
  11. // Dashboard timeframe. Narrows the consumption/cost columns only — the
  12. // spool count and remaining weight are point-in-time stock.
  13. dateFrom?: string;
  14. dateTo?: string;
  15. }
  16. function formatGrams(g: number): string {
  17. if (Math.abs(g) >= 1000) return `${(g / 1000).toFixed(2)} kg`;
  18. return `${Math.round(g)} g`;
  19. }
  20. export function MaterialNumberStats({ currency, dateFrom, dateTo }: MaterialNumberStatsProps) {
  21. const { t } = useTranslation();
  22. const { data, isLoading, isError } = useQuery({
  23. queryKey: ['material-number-stats', dateFrom ?? null, dateTo ?? null],
  24. queryFn: () => api.getMaterialNumberStats({ dateFrom, dateTo }),
  25. });
  26. if (isLoading) {
  27. return (
  28. <div className="flex items-center justify-center py-8">
  29. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  30. </div>
  31. );
  32. }
  33. // A failed request is not an empty inventory: telling someone who has
  34. // numbered their spools to go and number them (because of a 403 from a
  35. // missing INVENTORY_READ, or a dropped connection) sends them looking for
  36. // a problem that isn't there.
  37. if (isError) {
  38. return <p className="text-sm text-red-700 dark:text-red-400 py-4">{t('stats.materialNumbers.loadFailed')}</p>;
  39. }
  40. if (!data || data.length === 0) {
  41. return <p className="text-sm text-bambu-gray py-4">{t('stats.materialNumbers.empty')}</p>;
  42. }
  43. return (
  44. <div className="overflow-x-auto">
  45. <table className="w-full text-sm">
  46. <thead>
  47. <tr className="text-left text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
  48. <th className="py-2 pr-4 font-medium">{t('inventory.materialNumber')}</th>
  49. <th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.spools')}</th>
  50. <th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.remaining')}</th>
  51. <th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.consumed')}</th>
  52. <th className="py-2 font-medium text-right">{t('stats.materialNumbers.cost')}</th>
  53. </tr>
  54. </thead>
  55. <tbody>
  56. {data.map((row) => (
  57. <tr key={row.material_number} className="border-b border-bambu-dark-tertiary/50 last:border-b-0">
  58. <td className="py-2 pr-4 text-white font-medium">{row.material_number}</td>
  59. <td className="py-2 pr-4 text-bambu-gray text-right">{row.spool_count}</td>
  60. <td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.remaining_g)}</td>
  61. <td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.consumed_g)}</td>
  62. <td className="py-2 text-bambu-gray text-right">
  63. {currency} {row.cost.toFixed(2)}
  64. </td>
  65. </tr>
  66. ))}
  67. </tbody>
  68. </table>
  69. </div>
  70. );
  71. }