Ctrl K
ComunidadGuías…

dashboard/src/pages/ReportsPage.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { useState } from 'react';
import { Header } from '@/components/layout/Header';
import { Sidebar } from '@/components/layout/Sidebar';
import {
  useReportsQuery,
  useReportGroupMutations,
  ReportGroup,
  ReportFilters,
  ReportStatus,
  ReportStatusFilter,
} from '@/hooks/use-query/useReportsQuery';
import { useAppsQuery, AppListItem } from '@/hooks/use-query/useAppsQuery';
import { useChannelQuery, Channel } from '@/hooks/use-query/useChannelQuery';
import { usePlatformQuery, Platform } from '@/hooks/use-query/usePlatformQuery';
import { useArchitectureQuery, Architecture } from '@/hooks/use-query/useArchitectureQuery';
import { ReportBlobsModal } from '@/components/modals/ReportBlobsModal';
import { DeleteReportConfirmationModal } from '@/components/modals/DeleteReportConfirmationModal';
import { EditReportGroupModal } from '@/components/modals/EditReportGroupModal';
import { useToast } from '@/hooks/useToast';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import '@/styles/cards.css';
import { Dropdown } from '@/components/common/Dropdown';
import { DROPDOWN_MENU_STYLE, FIELD_INPUT, FIELD_LABEL } from '@/components/common/ui';

const PANEL_CLASS = 'bg-theme-card rounded-lg border border-theme-card-hover shadow-md backdrop-blur-lg';
const REPORTS_PAGE_LIMIT = 20;
const EVENT_TYPES = ['crash', 'startup_failure', 'update_failure', 'install_failure', 'rollback_failure'];
const STATUS_TABS: { value: ReportStatusFilter; label: string }[] = [
  { value: 'open', label: 'Open' },
  { value: 'resolved', label: 'Resolved' },
  { value: 'muted', label: 'Muted' },
  { value: 'all', label: 'All' },
];
const DEFAULT_STATUS: ReportStatusFilter = 'open';
const statusBadgeClass = (status: ReportStatus) => {
  switch (status) {
    case 'resolved':
      return 'bg-green-500/20 text-green-300 border-green-400/30';
    case 'muted':
      return 'bg-gray-500/20 text-gray-300 border-gray-400/30';
    default:
      return 'bg-amber-500/20 text-amber-300 border-amber-400/30';
  }
};

const formatDateTime = (value: string) => {
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) {
    return value;
  }
  const day = date.getDate();
  const month = date.toLocaleString('en-US', { month: 'short' });
  const year = date.getFullYear();
  const hours = date.getHours().toString().padStart(2, '0');
  const minutes = date.getMinutes().toString().padStart(2, '0');
  return `${day} ${month} ${year}, ${hours}:${minutes}`;
};

const formatLabel = (value: string | null | undefined) =>
  (value ?? '')
    .split('_')
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');

const getErrorMessage = (error: unknown, fallback: string): string => {
  if (typeof error === 'object' && error !== null) {
    const maybe = error as { response?: { data?: { error?: string; message?: string } }; message?: string };
    return maybe.response?.data?.error || maybe.response?.data?.message || maybe.message || fallback;
  }
  return fallback;
};

const eventTypeBadgeClass = (type: string | null | undefined) =>
  (type ?? '').toLowerCase().includes('failure') || (type ?? '').toLowerCase().includes('error')
    ? 'bg-red-500/20 text-red-300 border-red-400/30'
    : 'bg-blue-500/20 text-blue-300 border-blue-400/30';

const Badge = ({ label, className }: { label: string; className: string }) => (
  <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border whitespace-nowrap ${className}`}>
    {label}
  </span>
);

const FilterSelect = ({
  label,
  value,
  anyLabel,
  options,
  onSelect,
}: {
  label: string;
  value: string;
  anyLabel: string;
  options: string[];
  onSelect: (value: string) => void;
}) => (
  <div>
    <label className={FIELD_LABEL}>{label}</label>
    <Dropdown
      ariaLabel={label}
      placeholder={anyLabel}
      value={value}
      onChange={onSelect}
      options={[{ value: '', label: anyLabel }, ...options.map((option) => ({ value: option, label: option }))]}
    />
  </div>
);

export const ReportsPage = () => {
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);
  const [page, setPage] = useState(1);
  const [status, setStatus] = useState<ReportStatusFilter>(DEFAULT_STATUS);
  const [filters, setFilters] = useState<ReportFilters>({});
  const [fromDate, setFromDate] = useState<Date | null>(null);
  const [toDate, setToDate] = useState<Date | null>(null);
  const [selectedGroup, setSelectedGroup] = useState<ReportGroup | null>(null);
  const [groupToDelete, setGroupToDelete] = useState<ReportGroup | null>(null);
  const [groupToEdit, setGroupToEdit] = useState<ReportGroup | null>(null);
  const [pendingHash, setPendingHash] = useState<string | null>(null);

  const { toastSuccess, toastError } = useToast();
  const { apps = [] } = useAppsQuery();
  const { channels = [] } = useChannelQuery();
  const { platforms = [] } = usePlatformQuery();
  const { architectures = [] } = useArchitectureQuery();
  const { reports, isLoading } = useReportsQuery(page, REPORTS_PAGE_LIMIT, { ...filters, status });
  const { updateGroupMutation, deleteGroupMutation } = useReportGroupMutations();

  const items = reports?.items ?? [];
  const total = reports?.total ?? 0;
  const totalPages = Math.max(1, Math.ceil(total / REPORTS_PAGE_LIMIT));
  const hasActiveFilters = Object.values(filters).some(Boolean);

  const handlePageChange = (nextPage: number) => {
    setPage(Math.min(Math.max(1, nextPage), totalPages));
  };

  const updateFilter = (key: Exclude<keyof ReportFilters, 'status'>, value: string) => {
    setPage(1);
    setFilters((prev) => {
      const next = { ...prev };
      if (value) {
        next[key] = value;
      } else {
        delete next[key];
      }
      return next;
    });
  };

  const handleSelect = (key: Exclude<keyof ReportFilters, 'status'>) => (value: string) => {
    updateFilter(key, value);
  };

  const handleFromChange = (date: Date | null) => {
    setFromDate(date);
    updateFilter('from', date ? date.toISOString() : '');
  };

  const handleToChange = (date: Date | null) => {
    setToDate(date);
    updateFilter('to', date ? date.toISOString() : '');
  };

  const handleClearAll = () => {
    setPage(1);
    setFilters({});
    setFromDate(null);
    setToDate(null);
  };

  const handleStatusTab = (value: ReportStatusFilter) => {
    setPage(1);
    setStatus(value);
  };

  const handleUpdateStatus = (group: ReportGroup, next: ReportStatus) => {
    setPendingHash(group.group_hash);
    updateGroupMutation.mutate(
      { groupHash: group.group_hash, status: next },
      {
        onSuccess: () => {
          toastSuccess(`Group marked as ${next}`);
          setPendingHash(null);
        },
        onError: (error) => {
          toastError(getErrorMessage(error, 'Failed to update group status'));
          setPendingHash(null);
        },
      }
    );
  };

  const handleEditGroup = async (data: { tags: string[]; note: string }) => {
    if (!groupToEdit) return;
    try {
      await updateGroupMutation.mutateAsync({ groupHash: groupToEdit.group_hash, ...data });
      toastSuccess('Report group updated');
      setGroupToEdit(null);
    } catch (error) {
      toastError(getErrorMessage(error, 'Failed to update report group'));
    }
  };

  const handleDeleteGroup = async () => {
    if (!groupToDelete) return;
    try {
      await deleteGroupMutation.mutateAsync(groupToDelete.group_hash);
      toastSuccess('Report group deleted');
      setGroupToDelete(null);
    } catch (error) {
      toastError(getErrorMessage(error, 'Failed to delete report group'));
    }
  };

  const renderHeader = () => (
    <Header
      title="Reports"
      onMenuClick={() => setIsSidebarOpen(true)}
      onCreateClick={() => {}}
      createButtonText=""
      hideSearch={true}
    />
  );

  return (
    <div className="min-h-screen bg-theme-gradient font-sans">
      <div className="flex flex-col lg:flex-row">
        <Sidebar isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} />
        <main className="flex-1 min-w-0 p-2 sm:p-4 md:p-8">
          {renderHeader()}

          <div className="flex flex-wrap gap-2 mb-4 sm:mb-6">
            {STATUS_TABS.map((tab) => (
              <button
                key={tab.value}
                onClick={() => handleStatusTab(tab.value)}
                className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
                  status === tab.value
                    ? 'bg-theme-button-primary text-theme-primary shadow-sm'
                    : 'bg-theme-card text-theme-secondary hover:bg-theme-card-hover'
                }`}
              >
                {tab.label}
              </button>
            ))}
          </div>

          <div className={`${PANEL_CLASS} relative z-30 p-4 sm:p-6 mb-4 sm:mb-8`}>
            <div className="flex items-center justify-between mb-4">
              <h2 className="text-theme-primary text-base sm:text-lg font-semibold">Filters</h2>
              <div className="flex items-center gap-3">
                <span className="text-theme-secondary text-xs sm:text-sm">
                  {total} group{total === 1 ? '' : 's'} total
                </span>
                {hasActiveFilters && (
                  <button
                    onClick={handleClearAll}
                    className="header-additional-btn px-3 py-1.5 text-sm"
                  >
                    Clear all
                  </button>
                )}
              </div>
            </div>

            <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
              <FilterSelect
                label="Application"
                value={filters.app ?? ''}
                anyLabel="Any application"
                options={(apps as AppListItem[]).map((app) => app.AppName)}
                onSelect={handleSelect('app')}
              />
              <FilterSelect
                label="Channel"
                value={filters.channel ?? ''}
                anyLabel="Any channel"
                options={(channels as Channel[]).map((channel) => channel.ChannelName)}
                onSelect={handleSelect('channel')}
              />
              <FilterSelect
                label="Platform"
                value={filters.platform ?? ''}
                anyLabel="Any platform"
                options={(platforms as Platform[]).map((platform) => platform.PlatformName)}
                onSelect={handleSelect('platform')}
              />
              <FilterSelect
                label="Architecture"
                value={filters.arch ?? ''}
                anyLabel="Any architecture"
                options={(architectures as Architecture[]).map((arch) => arch.ArchID)}
                onSelect={handleSelect('arch')}
              />

              <div>
                <label className={FIELD_LABEL}>Version</label>
                <input
                  type="text"
                  value={filters.version ?? ''}
                  onChange={(e) => updateFilter('version', e.target.value)}
                  placeholder="e.g. 1.4.2"
                  className={FIELD_INPUT}
                />
              </div>
              <FilterSelect
                label="Event Type"
                value={filters.type ?? ''}
                anyLabel="Any event type"
                options={EVENT_TYPES}
                onSelect={handleSelect('type')}
              />
              <div>
                <label className={`${FIELD_LABEL} flex items-center gap-1.5`}>
                  Reason
                  <span className="relative group inline-flex">
                    <i className="fas fa-info-circle text-theme-secondary cursor-help"></i>
                    <span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-60 rounded-lg border border-theme-card-hover backdrop-blur-2xl px-3 py-2 text-xs text-theme-primary normal-case font-normal opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-[95]" style={DROPDOWN_MENU_STYLE}>
                      Use the raw value, not the label shown in the table. Lowercase with underscores instead of spaces — e.g. "Panic Nil Pointer" → <code>panic_nil_pointer</code>.
                    </span>
                  </span>
                </label>
                <input
                  type="text"
                  value={filters.reason ?? ''}
                  onChange={(e) => updateFilter('reason', e.target.value)}
                  placeholder="e.g. checksum_mismatch"
                  className={FIELD_INPUT}
                />
              </div>

              <div>
                <label className={FIELD_LABEL}>Last Seen From</label>
                <DatePicker
                  selected={fromDate}
                  onChange={handleFromChange}
                  showTimeSelect
                  dateFormat="yyyy-MM-dd HH:mm"
                  maxDate={new Date()}
                  placeholderText="Start date"
                  isClearable
                  className={FIELD_INPUT}
                  calendarClassName="react-datepicker"
                  popperClassName="z-[90]"
                />
              </div>
              <div>
                <label className={FIELD_LABEL}>Last Seen To</label>
                <DatePicker
                  selected={toDate}
                  onChange={handleToChange}
                  showTimeSelect
                  dateFormat="yyyy-MM-dd HH:mm"
                  minDate={fromDate ?? undefined}
                  maxDate={new Date()}
                  placeholderText="End date"
                  isClearable
                  className={FIELD_INPUT}
                  calendarClassName="react-datepicker"
                  popperClassName="z-[90]"
                />
              </div>
            </div>
          </div>

          {isLoading ? (
            <div className={`${PANEL_CLASS} flex items-center justify-center h-64`}>
              <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-theme-primary"></div>
            </div>
          ) : items.length === 0 ? (
            <div className={`${PANEL_CLASS} flex items-center justify-center h-64`}>
              <div className="text-center">
                <svg className="w-12 h-12 mx-auto text-theme-primary mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
                </svg>
                <p className="text-theme-primary opacity-75">
                  {hasActiveFilters ? 'No reports match the selected filters' : 'No reports available yet'}
                </p>
              </div>
            </div>
          ) : (
            <div className="users-table-container text-sm">
              <div className="overflow-x-auto">
                <table className="users-table">
                  <thead>
                    <tr>
                      <th className="px-3 py-2 text-left">Application</th>
                      <th className="px-3 py-2 text-left">System</th>
                      <th className="px-3 py-2 text-left">Event</th>
                      <th className="px-3 py-2 text-center">Occurrences</th>
                      <th className="px-3 py-2 text-left">First Seen</th>
                      <th className="px-3 py-2 text-left">Last Seen</th>
                      <th className="px-3 py-2 text-center">Status</th>
                      <th className="px-3 py-2 text-left">Tags / Note</th>
                      <th className="px-3 py-2 text-center">Details</th>
                      <th className="px-3 py-2 text-center">Actions</th>
                    </tr>
                  </thead>
                  <tbody>
                    {items.map((group: ReportGroup) => (
                      <tr key={group.id}>
                        <td className="px-3 py-2">
                          <div className="font-semibold text-theme-primary">{group.application.name}</div>
                          <div className="text-theme-secondary text-xs mt-0.5">
                            v{group.application.version} · {group.application.channel}
                          </div>
                        </td>
                        <td className="px-3 py-2">
                          <div className="text-theme-primary capitalize">{group.system.platform}</div>
                          <div className="text-theme-secondary text-xs mt-0.5 uppercase">{group.system.arch}</div>
                        </td>
                        <td className="px-3 py-2">
                          <div className="flex flex-wrap gap-1.5">
                            <Badge label={formatLabel(group.event.type)} className={eventTypeBadgeClass(group.event.type)} />
                            <Badge label={formatLabel(group.event.reason)} className="bg-amber-500/20 text-amber-300 border-amber-400/30" />
                          </div>
                        </td>
                        <td className="px-3 py-2 text-center">
                          <span className="font-semibold text-theme-primary tabular-nums">{group.stats.count}</span>
                        </td>
                        <td className="px-3 py-2 text-theme-secondary whitespace-nowrap">{formatDateTime(group.stats.first_seen)}</td>
                        <td className="px-3 py-2 text-theme-secondary whitespace-nowrap">{formatDateTime(group.stats.last_seen)}</td>
                        <td className="px-3 py-2 text-center">
                          <div className="flex flex-col items-center gap-1">
                            <Badge label={formatLabel(group.status)} className={statusBadgeClass(group.status)} />
                            {group.resolved_at && (
                              <div className="text-theme-secondary text-xs whitespace-nowrap">
                                {group.resolved_by && <span>by {group.resolved_by}</span>}
                                <div>{formatDateTime(group.resolved_at)}</div>
                              </div>
                            )}
                          </div>
                        </td>
                        <td className="px-3 py-2 align-top">
                          <div className="flex flex-col gap-1 max-w-[220px]">
                            {group.tags && group.tags.length > 0 && (
                              <div className="flex flex-wrap gap-1">
                                {group.tags.map((tag) => (
                                  <Badge key={tag} label={tag} className="bg-purple-500/20 text-purple-300 border-purple-400/30" />
                                ))}
                              </div>
                            )}
                            {group.note && (
                              <div className="flex items-start gap-1.5 text-theme-secondary text-xs" title={group.note}>
                                <i className="fas fa-sticky-note mt-0.5 flex-shrink-0"></i>
                                <span className="line-clamp-2 break-words">{group.note}</span>
                              </div>
                            )}
                            {!(group.tags && group.tags.length > 0) && !group.note && (
                              <span className="text-theme-secondary text-xs">—</span>
                            )}
                          </div>
                        </td>
                        <td className="px-3 py-2 text-center">
                          <div className="flex justify-center">
                            {group.stats.details_stored > 0 ? (
                              <button
                                onClick={() => setSelectedGroup(group)}
                                className="users-table-action-btn"
                                title={`View ${group.stats.details_stored} stored detail blob${group.stats.details_stored === 1 ? '' : 's'}`}
                              >
                                <i className="fas fa-folder-open"></i>
                              </button>
                            ) : (
                              <span className="text-theme-secondary text-xs">—</span>
                            )}
                          </div>
                        </td>
                        <td className="px-3 py-2 text-center">
                          <div className="flex justify-center gap-1">
                            <button
                              onClick={() => setGroupToEdit(group)}
                              className="users-table-action-btn"
                              title="Edit tags & note"
                            >
                              <i className="fas fa-pen"></i>
                            </button>
                            {group.status !== 'resolved' && (
                              <button
                                onClick={() => handleUpdateStatus(group, 'resolved')}
                                disabled={pendingHash === group.group_hash}
                                className="users-table-action-btn disabled:opacity-50 disabled:cursor-not-allowed"
                                title="Resolve"
                              >
                                <i className="fas fa-check text-green-400"></i>
                              </button>
                            )}
                            {group.status !== 'open' && (
                              <button
                                onClick={() => handleUpdateStatus(group, 'open')}
                                disabled={pendingHash === group.group_hash}
                                className="users-table-action-btn disabled:opacity-50 disabled:cursor-not-allowed"
                                title="Reopen"
                              >
                                <i className="fas fa-undo text-blue-400"></i>
                              </button>
                            )}
                            {group.status !== 'muted' && (
                              <button
                                onClick={() => handleUpdateStatus(group, 'muted')}
                                disabled={pendingHash === group.group_hash}
                                className="users-table-action-btn disabled:opacity-50 disabled:cursor-not-allowed"
                                title="Mute"
                              >
                                <i className="fas fa-bell-slash text-gray-400"></i>
                              </button>
                            )}
                            <button
                              onClick={() => setGroupToDelete(group)}
                              className="users-table-action-btn"
                              title="Delete permanently"
                            >
                              <i className="fas fa-trash text-red-400"></i>
                            </button>
                          </div>
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </div>
          )}

          {totalPages > 1 && (
            <div className="flex justify-center gap-2 mt-8">
              <button
                onClick={() => handlePageChange(1)}
                disabled={page === 1}
                className="px-4 py-2 rounded-lg bg-theme-card text-theme-primary hover:bg-theme-card-hover disabled:opacity-50 disabled:cursor-not-allowed"
                title="First page"
              >
                <i className="fas fa-angle-double-left"></i>
              </button>
              <button
                onClick={() => handlePageChange(page - 1)}
                disabled={page === 1}
                className="px-4 py-2 rounded-lg bg-theme-card text-theme-primary hover:bg-theme-card-hover disabled:opacity-50 disabled:cursor-not-allowed"
                title="Previous page"
              >
                <i className="fas fa-angle-left"></i>
              </button>
              <span className="px-4 py-2 text-theme-primary">
                Page {page} of {totalPages}
              </span>
              <button
                onClick={() => handlePageChange(page + 1)}
                disabled={page === totalPages}
                className="px-4 py-2 rounded-lg bg-theme-card text-theme-primary hover:bg-theme-card-hover disabled:opacity-50 disabled:cursor-not-allowed"
                title="Next page"
              >
                <i className="fas fa-angle-right"></i>
              </button>
              <button
                onClick={() => handlePageChange(totalPages)}
                disabled={page === totalPages}
                className="px-4 py-2 rounded-lg bg-theme-card text-theme-primary hover:bg-theme-card-hover disabled:opacity-50 disabled:cursor-not-allowed"
                title="Last page"
              >
                <i className="fas fa-angle-double-right"></i>
              </button>
            </div>
          )}
        </main>
      </div>

      {selectedGroup && (
        <ReportBlobsModal group={selectedGroup} onClose={() => setSelectedGroup(null)} />
      )}

      {groupToEdit && (
        <EditReportGroupModal
          group={groupToEdit}
          onClose={() => setGroupToEdit(null)}
          onConfirm={handleEditGroup}
        />
      )}

      {groupToDelete && (
        <DeleteReportConfirmationModal
          group={groupToDelete}
          onClose={() => setGroupToDelete(null)}
          onConfirm={handleDeleteGroup}
        />
      )}
    </div>
  );
};