Rétablit les fins de ligne CRLF sur les fichiers du commit précédent
Build & Deploy / build (push) Successful in 27s

Le script d'édition du commit précédent a réécrit ces cinq fichiers en LF alors
que le dépôt les stocke en CRLF, ce qui gonflait le diff au fichier entier et
aurait rendu les prochains merges pénibles. Contenu strictement identique, seules
les fins de ligne reviennent à la convention du dépôt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 20:40:30 +02:00
co-authored by Claude Opus 5
parent f170da8997
commit b86bd6fbc2
5 changed files with 1175 additions and 1175 deletions
+258 -258
View File
@@ -1,259 +1,259 @@
import { useEffect, useMemo, useState } from "react";
import {
Filter,
ArrowUpDown,
File as FileIcon,
} from "lucide-react";
import styles from "../styles/DocumentsPage.module.css";
import Pagination from "../components/Pagination";
import { getDocuments } from "../services/theapi";
type ApiDocument = {
title: string;
ingredient: string;
source: string;
type: string;
date?: string | null;
pdf_url: string;
bold?: boolean;
};
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function DocumentsPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [sortBy, setSortBy] = useState("Date (Newest)");
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getDocuments()
.then((data) => {
setDocuments(Array.isArray(data) ? data : []);
})
.catch((err) => {
console.error("API Error:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(documents.map((d) => d.source))],
[documents]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(documents.map((d) => d.type))],
[documents]
);
const allDates = useMemo(
() => [
"All Dates",
...new Set(
documents.map((d) => String(d.date ?? "").slice(0, 4))
),
],
[documents]
);
const filtered = useMemo(() => {
return documents
.filter((d) => {
const sourceMatch =
sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch =
dateFilter === "All Dates" ||
safeDate.startsWith(dateFilter);
return sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => {
const dateA = String(a.date ?? "");
const dateB = String(b.date ?? "");
const titleA = String(a.title ?? "");
const titleB = String(b.title ?? "");
if (sortBy === "Date (Newest)") return dateB.localeCompare(dateA);
if (sortBy === "Date (Oldest)") return dateA.localeCompare(dateB);
if (sortBy === "Title (A-Z)") return titleA.localeCompare(titleB);
if (sortBy === "Title (Z-A)") return titleB.localeCompare(titleA);
return 0;
});
}, [documents, sourceFilter, typeFilter, dateFilter, sortBy]);
const totalPages = Math.max(
1,
Math.ceil(filtered.length / itemsPerPage)
);
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * itemsPerPage,
safePage * itemsPerPage
);
// adjust items per page based on viewport width
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return (
<div className={styles.container}>
<p>Loading documents...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h2 className={styles.title}>Document Database</h2>
<p className={styles.description}>
Complete collection of regulatory monitoring documents
</p>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters:</span>
</div>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className={styles.select}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className={styles.select}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<select
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className={styles.select}
>
{allDates.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<div className={styles.sortWrapper}>
<ArrowUpDown size={16} />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className={styles.select}
>
<option value="Date (Newest)">Date (Newest)</option>
<option value="Date (Oldest)">Date (Oldest)</option>
<option value="Title (A-Z)">Title (A-Z)</option>
<option value="Title (Z-A)">Title (Z-A)</option>
</select>
</div>
</div>
{/* STATS */}
<div className={styles.statsRow}>
<p className={styles.statsText}>
Total Documents:
<span className={styles.statsNumber}>
{" "}
{filtered.length}
</span>
</p>
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span>Title</span>
<span>Ingredient</span>
<span>Source</span>
<span>Type</span>
<span>Date</span>
<span>PDF</span>
</div>
{paginated.map((doc, index) => (
<div
key={`${doc.title}-${doc.source}-${doc.date ?? index}`}
className={styles.tableRow}
>
<div className={styles.titleCell}>
<FileIcon size={16} />
<span>{doc.title}</span>
</div>
<span>{doc.ingredient}</span>
<span>{doc.source}</span>
<span>{doc.type}</span>
<span>{doc.date}</span>
<div>
<a
href={doc.pdf_url}
target="_blank"
rel="noreferrer"
>
<button className={styles.openButton}>
Open
</button>
</a>
</div>
</div>
))}
</div>
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
</div>
);
import { useEffect, useMemo, useState } from "react";
import {
Filter,
ArrowUpDown,
File as FileIcon,
} from "lucide-react";
import styles from "../styles/DocumentsPage.module.css";
import Pagination from "../components/Pagination";
import { getDocuments } from "../services/theapi";
type ApiDocument = {
title: string;
ingredient: string;
source: string;
type: string;
date?: string | null;
pdf_url: string;
bold?: boolean;
};
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function DocumentsPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [sortBy, setSortBy] = useState("Date (Newest)");
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getDocuments()
.then((data) => {
setDocuments(Array.isArray(data) ? data : []);
})
.catch((err) => {
console.error("API Error:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(documents.map((d) => d.source))],
[documents]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(documents.map((d) => d.type))],
[documents]
);
const allDates = useMemo(
() => [
"All Dates",
...new Set(
documents.map((d) => String(d.date ?? "").slice(0, 4))
),
],
[documents]
);
const filtered = useMemo(() => {
return documents
.filter((d) => {
const sourceMatch =
sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch =
dateFilter === "All Dates" ||
safeDate.startsWith(dateFilter);
return sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => {
const dateA = String(a.date ?? "");
const dateB = String(b.date ?? "");
const titleA = String(a.title ?? "");
const titleB = String(b.title ?? "");
if (sortBy === "Date (Newest)") return dateB.localeCompare(dateA);
if (sortBy === "Date (Oldest)") return dateA.localeCompare(dateB);
if (sortBy === "Title (A-Z)") return titleA.localeCompare(titleB);
if (sortBy === "Title (Z-A)") return titleB.localeCompare(titleA);
return 0;
});
}, [documents, sourceFilter, typeFilter, dateFilter, sortBy]);
const totalPages = Math.max(
1,
Math.ceil(filtered.length / itemsPerPage)
);
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * itemsPerPage,
safePage * itemsPerPage
);
// adjust items per page based on viewport width
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return (
<div className={styles.container}>
<p>Loading documents...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h2 className={styles.title}>Document Database</h2>
<p className={styles.description}>
Complete collection of regulatory monitoring documents
</p>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters:</span>
</div>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className={styles.select}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className={styles.select}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<select
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className={styles.select}
>
{allDates.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<div className={styles.sortWrapper}>
<ArrowUpDown size={16} />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className={styles.select}
>
<option value="Date (Newest)">Date (Newest)</option>
<option value="Date (Oldest)">Date (Oldest)</option>
<option value="Title (A-Z)">Title (A-Z)</option>
<option value="Title (Z-A)">Title (Z-A)</option>
</select>
</div>
</div>
{/* STATS */}
<div className={styles.statsRow}>
<p className={styles.statsText}>
Total Documents:
<span className={styles.statsNumber}>
{" "}
{filtered.length}
</span>
</p>
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span>Title</span>
<span>Ingredient</span>
<span>Source</span>
<span>Type</span>
<span>Date</span>
<span>PDF</span>
</div>
{paginated.map((doc, index) => (
<div
key={`${doc.title}-${doc.source}-${doc.date ?? index}`}
className={styles.tableRow}
>
<div className={styles.titleCell}>
<FileIcon size={16} />
<span>{doc.title}</span>
</div>
<span>{doc.ingredient}</span>
<span>{doc.source}</span>
<span>{doc.type}</span>
<span>{doc.date}</span>
<div>
<a
href={doc.pdf_url}
target="_blank"
rel="noreferrer"
>
<button className={styles.openButton}>
Open
</button>
</a>
</div>
</div>
))}
</div>
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
</div>
);
}
+191 -191
View File
@@ -1,192 +1,192 @@
import { useEffect, useMemo, useState } from "react";
import { Search, Filter } from "lucide-react";
import styles from "../styles/IngredientSearchPage.module.css";
import Pagination from "../components/Pagination";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
export default function IngredientSearchPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(8);
// FETCH API
useEffect(() => {
getDocuments()
.then((data) => setDocuments(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
// responsive items per page
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(8);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
// FILTER OPTIONS
const allSources = useMemo(() => [
"All Sources",
...new Set(documents.map((d) => d.source)),
], [documents]);
const allTypes = useMemo(() => [
"All Types",
...new Set(documents.map((d) => d.type)),
], [documents]);
const allDates = useMemo(() => [
"All Dates",
...new Set(documents.map((d) => String(d.date ?? "").slice(0, 4))).values(),
], [documents]);
// FILTERED RESULTS
const filtered = useMemo(() => {
return documents
.filter((d) => {
const queryMatch =
searchQuery === "" ||
d.ingredient.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.title.toLowerCase().includes(searchQuery.toLowerCase());
const sourceMatch = sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch = typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch = dateFilter === "All Dates" || safeDate.startsWith(dateFilter);
return queryMatch && sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => String(b.date ?? "").localeCompare(String(a.date ?? "")));
}, [documents, searchQuery, sourceFilter, typeFilter, dateFilter]);
if (loading) {
return <div className={styles.container}><p>Loading...</p></div>;
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h1 className={styles.title}>Ingredient Search</h1>
<p className={styles.subtitle}>Search cosmetic ingredients and regulatory documents</p>
</div>
{/* SEARCH */}
<div className={styles.searchBarWrapper}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
type="text"
placeholder="Search ingredient..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search size={20} className={styles.searchIcon} />
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterHeader}>
<Filter size={16} className={styles.filterIcon} />
<span>Filters</span>
</div>
<div className={styles.filterControls}>
<select className={styles.select} value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
{allSources.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select className={styles.select} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
{allTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select className={styles.select} value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}>
{allDates.map((d) => <option key={d} value={d}>{d || "Unknown"}</option>)}
</select>
</div>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span> results found
</div>
{/* DOCUMENTS */}
<div className={styles.documentsList}>
{filtered.length === 0 ? (
<div className={styles.emptyState}>
<p className={styles.emptyMessage}>No documents found.</p>
</div>
) : (
(() => {
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
return (
<>
{paginated.map((doc) => (
<div key={doc.id} className={styles.documentCard}>
<div className={styles.documentCardFlex}>
<div className={styles.documentContent}>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Ingredient</span>
<span className={styles.metaValue}>{doc.ingredient}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Source</span>
<span className={styles.metaValue}>{doc.source}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>{doc.type}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Date</span>
<span className={styles.metaValue}>{doc.date ?? "—"}</span>
</div>
</div>
</div>
{doc.pdf_url && (
<button
className={styles.openButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
)}
</div>
</div>
))}
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</>
);
})()
)}
</div>
</div>
</div>
);
import { useEffect, useMemo, useState } from "react";
import { Search, Filter } from "lucide-react";
import styles from "../styles/IngredientSearchPage.module.css";
import Pagination from "../components/Pagination";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
export default function IngredientSearchPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(8);
// FETCH API
useEffect(() => {
getDocuments()
.then((data) => setDocuments(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
// responsive items per page
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(8);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
// FILTER OPTIONS
const allSources = useMemo(() => [
"All Sources",
...new Set(documents.map((d) => d.source)),
], [documents]);
const allTypes = useMemo(() => [
"All Types",
...new Set(documents.map((d) => d.type)),
], [documents]);
const allDates = useMemo(() => [
"All Dates",
...new Set(documents.map((d) => String(d.date ?? "").slice(0, 4))).values(),
], [documents]);
// FILTERED RESULTS
const filtered = useMemo(() => {
return documents
.filter((d) => {
const queryMatch =
searchQuery === "" ||
d.ingredient.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.title.toLowerCase().includes(searchQuery.toLowerCase());
const sourceMatch = sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch = typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch = dateFilter === "All Dates" || safeDate.startsWith(dateFilter);
return queryMatch && sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => String(b.date ?? "").localeCompare(String(a.date ?? "")));
}, [documents, searchQuery, sourceFilter, typeFilter, dateFilter]);
if (loading) {
return <div className={styles.container}><p>Loading...</p></div>;
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h1 className={styles.title}>Ingredient Search</h1>
<p className={styles.subtitle}>Search cosmetic ingredients and regulatory documents</p>
</div>
{/* SEARCH */}
<div className={styles.searchBarWrapper}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
type="text"
placeholder="Search ingredient..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search size={20} className={styles.searchIcon} />
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterHeader}>
<Filter size={16} className={styles.filterIcon} />
<span>Filters</span>
</div>
<div className={styles.filterControls}>
<select className={styles.select} value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
{allSources.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select className={styles.select} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
{allTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select className={styles.select} value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}>
{allDates.map((d) => <option key={d} value={d}>{d || "Unknown"}</option>)}
</select>
</div>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span> results found
</div>
{/* DOCUMENTS */}
<div className={styles.documentsList}>
{filtered.length === 0 ? (
<div className={styles.emptyState}>
<p className={styles.emptyMessage}>No documents found.</p>
</div>
) : (
(() => {
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
return (
<>
{paginated.map((doc) => (
<div key={doc.id} className={styles.documentCard}>
<div className={styles.documentCardFlex}>
<div className={styles.documentContent}>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Ingredient</span>
<span className={styles.metaValue}>{doc.ingredient}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Source</span>
<span className={styles.metaValue}>{doc.source}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>{doc.type}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Date</span>
<span className={styles.metaValue}>{doc.date ?? "—"}</span>
</div>
</div>
</div>
{doc.pdf_url && (
<button
className={styles.openButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
)}
</div>
</div>
))}
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</>
);
})()
)}
</div>
</div>
</div>
);
}
+211 -211
View File
@@ -1,212 +1,212 @@
import { useEffect, useMemo, useState } from "react";
import { TrendingUp, Filter, Eye } from "lucide-react";
import styles from "../styles/RecentUpdatesPage.module.css";
import Pagination from "../components/Pagination";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function RecentUpdatesPage() {
const [updates, setUpdates] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
useEffect(() => {
getDocuments()
.then((data) => setUpdates(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setUpdates([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(updates.map((u) => u.source))],
[updates]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(updates.map((u) => u.type))],
[updates]
);
const filtered = useMemo(() => {
return updates
.filter((u) => {
const sourceMatch =
sourceFilter === "All Sources" || u.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || u.type === typeFilter;
return sourceMatch && typeMatch;
})
.sort((a, b) =>
String(b.date ?? "").localeCompare(String(a.date ?? ""))
);
}, [updates, sourceFilter, typeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * itemsPerPage,
safePage * itemsPerPage
);
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return (
<div className={styles.container}>
<p>Loading updates...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<div className={styles.headerFlex}>
<TrendingUp size={28} className={styles.headerIcon} />
<h1 className={styles.title}>Recent Updates</h1>
</div>
<p className={styles.subtitle}>
Latest cosmetic regulatory updates and monitoring activity
</p>
</div>
{/* STATS */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<p className={styles.statLabel}>Total Updates</p>
<h2 className={styles.statValue}>{updates.length}</h2>
<div className={`${styles.statIcon} ${styles.statIconBlue}`}>
<TrendingUp size={18} />
</div>
</div>
<div className={styles.statCard}>
<p className={styles.statLabel}>Sources</p>
<h2 className={styles.statValue}>{allSources.length - 1}</h2>
<div className={`${styles.statIcon} ${styles.statIconGreen}`}>
<Filter size={18} />
</div>
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters</span>
</div>
<select
className={styles.select}
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
className={styles.select}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span>{" "}
updates found
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span className={styles.headerCell}>Status</span>
<span className={styles.headerCell}>Title</span>
<span className={styles.headerCell}>Ingredient</span>
<span className={styles.headerCell}>Source</span>
<span className={styles.headerCell}>Type</span>
<span className={styles.headerCell}>Date</span>
<span className={styles.headerCell}>View</span>
</div>
{paginated.map((u, index) => (
<div
key={u.id}
className={`${styles.tableRow} ${
index < paginated.length - 1
? styles.tableRowBorder
: ""
}`}
>
<div>
<span
className={`${styles.statusBadge} ${styles.statusBadgeNew}`}
>
New
</span>
</div>
<div className={`${styles.titleCell} ${styles.cellTruncate}`}>
{u.title}
</div>
<div className={styles.cell}>{u.ingredient}</div>
<div className={styles.cell}>{u.source}</div>
<div className={styles.cell}>{u.type}</div>
<div className={styles.cell}>{u.date ?? "—"}</div>
<div>
{u.pdf_url && (
<button
className={styles.viewButton}
onClick={() => openPdf(u.pdf_url)}
>
<Eye size={14} />
</button>
)}
</div>
</div>
))}
</div>
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
</div>
);
import { useEffect, useMemo, useState } from "react";
import { TrendingUp, Filter, Eye } from "lucide-react";
import styles from "../styles/RecentUpdatesPage.module.css";
import Pagination from "../components/Pagination";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
const DEFAULT_ITEMS_PER_PAGE = 8;
export default function RecentUpdatesPage() {
const [updates, setUpdates] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(DEFAULT_ITEMS_PER_PAGE);
useEffect(() => {
getDocuments()
.then((data) => setUpdates(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setUpdates([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(updates.map((u) => u.source))],
[updates]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(updates.map((u) => u.type))],
[updates]
);
const filtered = useMemo(() => {
return updates
.filter((u) => {
const sourceMatch =
sourceFilter === "All Sources" || u.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || u.type === typeFilter;
return sourceMatch && typeMatch;
})
.sort((a, b) =>
String(b.date ?? "").localeCompare(String(a.date ?? ""))
);
}, [updates, sourceFilter, typeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / itemsPerPage));
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * itemsPerPage,
safePage * itemsPerPage
);
useEffect(() => {
const apply = () => {
const w = window.innerWidth;
if (w < 640) setItemsPerPage(3);
else if (w < 900) setItemsPerPage(6);
else setItemsPerPage(DEFAULT_ITEMS_PER_PAGE);
};
apply();
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}, []);
if (loading) {
return (
<div className={styles.container}>
<p>Loading updates...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<div className={styles.headerFlex}>
<TrendingUp size={28} className={styles.headerIcon} />
<h1 className={styles.title}>Recent Updates</h1>
</div>
<p className={styles.subtitle}>
Latest cosmetic regulatory updates and monitoring activity
</p>
</div>
{/* STATS */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<p className={styles.statLabel}>Total Updates</p>
<h2 className={styles.statValue}>{updates.length}</h2>
<div className={`${styles.statIcon} ${styles.statIconBlue}`}>
<TrendingUp size={18} />
</div>
</div>
<div className={styles.statCard}>
<p className={styles.statLabel}>Sources</p>
<h2 className={styles.statValue}>{allSources.length - 1}</h2>
<div className={`${styles.statIcon} ${styles.statIconGreen}`}>
<Filter size={18} />
</div>
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters</span>
</div>
<select
className={styles.select}
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
className={styles.select}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span>{" "}
updates found
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span className={styles.headerCell}>Status</span>
<span className={styles.headerCell}>Title</span>
<span className={styles.headerCell}>Ingredient</span>
<span className={styles.headerCell}>Source</span>
<span className={styles.headerCell}>Type</span>
<span className={styles.headerCell}>Date</span>
<span className={styles.headerCell}>View</span>
</div>
{paginated.map((u, index) => (
<div
key={u.id}
className={`${styles.tableRow} ${
index < paginated.length - 1
? styles.tableRowBorder
: ""
}`}
>
<div>
<span
className={`${styles.statusBadge} ${styles.statusBadgeNew}`}
>
New
</span>
</div>
<div className={`${styles.titleCell} ${styles.cellTruncate}`}>
{u.title}
</div>
<div className={styles.cell}>{u.ingredient}</div>
<div className={styles.cell}>{u.source}</div>
<div className={styles.cell}>{u.type}</div>
<div className={styles.cell}>{u.date ?? "—"}</div>
<div>
{u.pdf_url && (
<button
className={styles.viewButton}
onClick={() => openPdf(u.pdf_url)}
>
<Eye size={14} />
</button>
)}
</div>
</div>
))}
</div>
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
</div>
);
}
+237 -237
View File
@@ -1,237 +1,237 @@
.container {
padding: 2rem;
}
.maxWidth {
max-width: 80rem;
}
.header {
margin-bottom: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
line-height: 1.1;
}
.description {
color: rgb(75, 85, 99);
margin-bottom: 1.5rem;
font-size: 1.125rem;
line-height: 1.6;
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.sortWrapper {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sortIcon {
color: rgb(107, 114, 128);
}
.statsRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.statsText {
font-size: 0.875rem;
color: rgb(55, 65, 81);
}
.statsNumber {
font-weight: 600;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.25rem;
color: rgb(17, 24, 39);
}
.tableRow {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
color: rgb(17, 24, 39);
}
.tableRow span,
.titleCell span,
.titleCell {
color: rgb(17, 24, 39);
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 2fr 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.titleCell {
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.titleIcon {
color: rgb(156, 163, 175);
margin-top: 0.125rem;
flex-shrink: 0;
}
.titleText {
font-size: 0.875rem;
color: rgb(31, 41, 55);
line-height: 1.5;
}
.ingredientBadge {
display: inline-block;
font-size: 0.75rem;
padding: 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgb(209, 213, 219);
background-color: rgb(243, 244, 246);
}
.ingredientBadgeBold {
font-weight: 600;
border-color: rgb(209, 213, 219);
color: rgb(17, 24, 39);
}
.ingredientBadgeNormal {
border-color: rgb(229, 231, 235);
color: rgb(55, 65, 81);
}
.cell {
font-size: 0.875rem;
color: rgb(17, 24, 39);
}
.cellWhitespace {
white-space: nowrap;
}
.openButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.875rem;
font-weight: 500;
padding: 0.375rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.openButton:hover {
background-color: rgb(29, 78, 216);
}
/* Mobile: make each table row a card */
@media (max-width: 640px) {
.tableHeader { display: none; }
.tableRow { display: block; padding: 12px; border-bottom: 1px solid rgb(229,231,235); }
.titleCell { font-weight:700; font-size:1rem; display:flex; align-items:center; gap:8px }
.tableRow span { display:block; margin-top:6px; color: rgb(17,24,39) }
.openButton { width:100%; display:block }
}
.container {
padding: 2rem;
}
.maxWidth {
max-width: 80rem;
}
.header {
margin-bottom: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
line-height: 1.1;
}
.description {
color: rgb(75, 85, 99);
margin-bottom: 1.5rem;
font-size: 1.125rem;
line-height: 1.6;
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.sortWrapper {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sortIcon {
color: rgb(107, 114, 128);
}
.statsRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.statsText {
font-size: 0.875rem;
color: rgb(55, 65, 81);
}
.statsNumber {
font-weight: 600;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.25rem;
color: rgb(17, 24, 39);
}
.tableRow {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
color: rgb(17, 24, 39);
}
.tableRow span,
.titleCell span,
.titleCell {
color: rgb(17, 24, 39);
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 2fr 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.titleCell {
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.titleIcon {
color: rgb(156, 163, 175);
margin-top: 0.125rem;
flex-shrink: 0;
}
.titleText {
font-size: 0.875rem;
color: rgb(31, 41, 55);
line-height: 1.5;
}
.ingredientBadge {
display: inline-block;
font-size: 0.75rem;
padding: 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgb(209, 213, 219);
background-color: rgb(243, 244, 246);
}
.ingredientBadgeBold {
font-weight: 600;
border-color: rgb(209, 213, 219);
color: rgb(17, 24, 39);
}
.ingredientBadgeNormal {
border-color: rgb(229, 231, 235);
color: rgb(55, 65, 81);
}
.cell {
font-size: 0.875rem;
color: rgb(17, 24, 39);
}
.cellWhitespace {
white-space: nowrap;
}
.openButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.875rem;
font-weight: 500;
padding: 0.375rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.openButton:hover {
background-color: rgb(29, 78, 216);
}
/* Mobile: make each table row a card */
@media (max-width: 640px) {
.tableHeader { display: none; }
.tableRow { display: block; padding: 12px; border-bottom: 1px solid rgb(229,231,235); }
.titleCell { font-weight:700; font-size:1rem; display:flex; align-items:center; gap:8px }
.tableRow span { display:block; margin-top:6px; color: rgb(17,24,39) }
.openButton { width:100%; display:block }
}
+278 -278
View File
@@ -1,278 +1,278 @@
.container {
padding: 2rem;
}
.maxWidth {
max-width: 96rem;
}
.header {
margin-bottom: 2rem;
}
.headerFlex {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.headerIcon {
color: rgb(37, 99, 235);
font-size: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
line-height: 1.1;
}
.subtitle {
color: rgb(75, 85, 99);
margin-bottom: 2rem;
font-size: 1.125rem;
line-height: 1.6;
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.statCard {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
}
.statLabel {
color: rgb(75, 85, 99);
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.statValue {
font-size: 1.875rem;
font-weight: bold;
color: rgb(17, 24, 39);
}
.statIcon {
margin-top: 0.75rem;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.statIconBlue {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.statIconGreen {
background-color: rgb(220, 252, 231);
color: rgb(34, 197, 94);
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.exportWrapper {
margin-left: auto;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.resultsInfo {
color: rgb(75, 85, 99);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.resultsCount {
font-weight: 600;
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.5rem;
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
padding: 1rem 1.5rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.statusBadge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.statusBadgeNew {
background-color: rgb(220, 252, 231);
color: rgb(22, 163, 74);
}
.statusBadgeUpdated {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.titleCell {
font-weight: 500;
color: rgb(17, 24, 39);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell {
font-size: 0.875rem;
color: rgb(75, 85, 99);
}
.cellTruncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.viewButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.viewButton:hover {
background-color: rgb(29, 78, 216);
}
@media (max-width: 1024px) {
.statsGrid {
grid-template-columns: repeat(2, 1fr);
}
.tableHeader,
.tableRow {
grid-template-columns: 0.6fr 1.5fr 0.8fr 1fr 0.8fr 0.6fr 0.5fr;
}
}
@media (max-width: 768px) {
.statsGrid {
grid-template-columns: 1fr;
}
.tableHeader,
.tableRow {
grid-template-columns: 0.6fr 1.2fr 0.6fr 0.8fr 0.6fr 0.5fr 0.4fr;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.headerCell,
.cell {
font-size: 0.75rem;
}
}
.container {
padding: 2rem;
}
.maxWidth {
max-width: 96rem;
}
.header {
margin-bottom: 2rem;
}
.headerFlex {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.headerIcon {
color: rgb(37, 99, 235);
font-size: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
line-height: 1.1;
}
.subtitle {
color: rgb(75, 85, 99);
margin-bottom: 2rem;
font-size: 1.125rem;
line-height: 1.6;
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.statCard {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
}
.statLabel {
color: rgb(75, 85, 99);
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.statValue {
font-size: 1.875rem;
font-weight: bold;
color: rgb(17, 24, 39);
}
.statIcon {
margin-top: 0.75rem;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.statIconBlue {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.statIconGreen {
background-color: rgb(220, 252, 231);
color: rgb(34, 197, 94);
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.exportWrapper {
margin-left: auto;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.resultsInfo {
color: rgb(75, 85, 99);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.resultsCount {
font-weight: 600;
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.5rem;
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
padding: 1rem 1.5rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.statusBadge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.statusBadgeNew {
background-color: rgb(220, 252, 231);
color: rgb(22, 163, 74);
}
.statusBadgeUpdated {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.titleCell {
font-weight: 500;
color: rgb(17, 24, 39);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell {
font-size: 0.875rem;
color: rgb(75, 85, 99);
}
.cellTruncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.viewButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.viewButton:hover {
background-color: rgb(29, 78, 216);
}
@media (max-width: 1024px) {
.statsGrid {
grid-template-columns: repeat(2, 1fr);
}
.tableHeader,
.tableRow {
grid-template-columns: 0.6fr 1.5fr 0.8fr 1fr 0.8fr 0.6fr 0.5fr;
}
}
@media (max-width: 768px) {
.statsGrid {
grid-template-columns: 1fr;
}
.tableHeader,
.tableRow {
grid-template-columns: 0.6fr 1.2fr 0.6fr 0.8fr 0.6fr 0.5fr 0.4fr;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.headerCell,
.cell {
font-size: 0.75rem;
}
}