feat(ui): qualify calendar documents and correspondence workflows

This commit is contained in:
cesnimda
2026-11-03 15:11:53 +01:00
parent 194e01ed7f
commit fba80ef346
158 changed files with 1961 additions and 14 deletions
@@ -154,6 +154,8 @@ export default function CorrespondenceInboxPage() {
const [providersLoading, setProvidersLoading] = useState(false);
const providerPending = useRef(false);
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
const messagesPanel = useRef<HTMLDivElement>(null);
const searchField = useRef<HTMLInputElement>(null);
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<ViewResourceError | null>(null);
@@ -755,8 +757,8 @@ export default function CorrespondenceInboxPage() {
{reviewVisited ? <Box hidden={view !== "review"}><GmailReviewPage embedded active={view === "review"} /></Box> : null}
<Box ref={inboxPanel} role="group" tabIndex={-1} aria-label={t("emailInboxLinkedMessages")} hidden={view !== "inbox"}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField label={t("correspondenceSearch")} value={query} disabled={!!unlinkingThreadId} onChange={(e) => updateFilters({ q: e.target.value, page: "1" })} placeholder={t("emailInboxSearchPlaceholder")} />
<Box component="aside" aria-label={t("emailInboxFilters")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField inputRef={searchField} label={t("correspondenceSearch")} value={query} disabled={!!unlinkingThreadId} onChange={(e) => updateFilters({ q: e.target.value, page: "1" })} placeholder={t("emailInboxSearchPlaceholder")} />
<FormControl fullWidth>
<InputLabel id="email-direction-label">{t("emailInboxDirection")}</InputLabel>
<Select labelId="email-direction-label" value={direction} disabled={!!unlinkingThreadId} label={t("emailInboxDirection")} onChange={(e) => updateFilters({ direction: String(e.target.value), page: "1" })}>
@@ -775,6 +777,7 @@ export default function CorrespondenceInboxPage() {
</Select>
</FormControl>
<Button variant="outlined" onClick={() => { inboxPanel.current?.focus({ preventScroll: true }); void load(); }} disabled={loading || !!unlinkingThreadId}>{loading ? t("loading") : t("refresh")}</Button>
<Button size="small" onClick={() => messagesPanel.current?.focus()} sx={{ justifySelf: "start", gridColumn: "1 / -1" }}>{t("emailInboxJumpMessages")}</Button>
</Box>
{draft ? (
@@ -816,6 +819,8 @@ export default function CorrespondenceInboxPage() {
</Paper>
) : null}
<Box ref={messagesPanel} role="region" aria-label={t("emailInboxLinkedMessages")} tabIndex={-1} aria-busy={loading} sx={{ minWidth: 0 }}>
<Button size="small" sx={{ mb: 1 }} disabled={!!unlinkingThreadId} onClick={() => searchField.current?.focus()}>{t("emailInboxReturnFilters")}</Button>
<ViewStateNotice compact loading={loading} title={t("emailInboxLoadFailed")} error={loadError ? { ...loadError, message: loadError.message || t("emailInboxLoadFailed") } : null} onRetry={() => { inboxPanel.current?.focus({ preventScroll: true }); return load(); }} />
<ViewStateNotice compact loading={!!unlinkingThreadId} title={t("emailInboxUnlinkFailed")} error={unlinkError ? { ...unlinkError, message: unlinkError.message || t("emailInboxUnlinkFailed") } : null} />
@@ -885,6 +890,7 @@ export default function CorrespondenceInboxPage() {
</Box>
) : null}
</Box>
</Box>
</PageSection>
);
}
+14 -4
View File
@@ -82,6 +82,7 @@ export default function DocumentsPage() {
const listController = useRef<AbortController | null>(null);
const mounted = useRef(true);
const resultsRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [downloadState, setDownloadState] = useState<DownloadState | null>(null);
const downloadController = useRef<AbortController | null>(null);
const downloadFocus = useRef<{ button: HTMLButtonElement; key: string } | null>(null);
@@ -214,9 +215,16 @@ export default function DocumentsPage() {
const label: TranslationKey = item.dateKind === "uploaded-local" ? "documentsUploadedLocal" : item.dateKind === "captured-utc" ? "documentsCaptured" : item.dateKind === "history-recorded-utc" ? "documentsHistoryRecorded" : "documentsUpdated";
let value: Date | null = null;
if (item.recordedAt && item.dateKind === "uploaded-local") {
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)/.exec(item.recordedAt);
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.\d{1,7})?$/.exec(item.recordedAt);
// UTC is only a formatting carrier for the unzoned wall-clock components.
if (local) value = new Date(Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +local[6]));
if (local) {
const parts = local.slice(1).map(Number);
const candidate = new Date(0);
candidate.setUTCFullYear(parts[0], parts[1] - 1, parts[2]);
candidate.setUTCHours(parts[3], parts[4], parts[5], 0);
// Date normalises impossible fields; show unknown rather than inventing a date.
if ([candidate.getUTCFullYear(), candidate.getUTCMonth() + 1, candidate.getUTCDate(), candidate.getUTCHours(), candidate.getUTCMinutes(), candidate.getUTCSeconds()].every((part, index) => part === parts[index])) value = candidate;
}
} else if (item.recordedAt && ["updated-utc", "captured-utc", "history-recorded-utc"].includes(item.dateKind)) value = new Date(item.recordedAt);
if (!value || Number.isNaN(value.valueOf())) return <Typography variant="body2" color="text.secondary">{t("documentsDateUnknown")}</Typography>;
return <Box><Typography variant="caption" color="text.secondary">{t(label)}</Typography><Typography variant="body2" component="time" dateTime={item.recordedAt!} sx={{ display: "block" }}>{value.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short", ...(item.dateKind === "uploaded-local" ? { timeZone: "UTC" } : {}) })}</Typography></Box>;
@@ -267,8 +275,8 @@ export default function DocumentsPage() {
return <Stack spacing={2} data-content-density="comfortable" sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("documentsYourDocuments")} description={t("documentsIndexHelp")} actions={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh} disabled={loading} sx={buttonSx}>{t("documentsRefresh")}</Button>} />
<Stack spacing={2}>
<TextField fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} />
<Stack component="aside" aria-label={t("documentsFilters")} spacing={2}>
<TextField inputRef={searchRef} fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2, minWidth: 0 }}>
{filter("type", "documentsType", type, types)}
{filter("state", "documentsState", state, states)}
@@ -278,8 +286,10 @@ export default function DocumentsPage() {
</Box>
{jobsError ? <ViewStateNotice compact title={t("documentsJobsLoadFailed")} error={{ ...jobsError, message: t("documentsJobsLoadFailed") }} onRetry={() => { resultsRef.current?.focus(); return loadJobs(); }} /> : null}
<Typography variant="caption" color="text.secondary">{t("documentsDateOrderHelp")}</Typography>
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resultsRef.current?.focus()}>{t("documentsJumpResults")}</Button>
</Stack>
<Box ref={resultsRef} role="region" aria-label={t("documentsYourDocuments")} tabIndex={-1} aria-busy={loading || !current && !error} sx={{ minWidth: 0 }}>
<Button size="small" sx={{ mb: 1 }} onClick={() => searchRef.current?.focus()}>{t("documentsReturnFilters")}</Button>
<ViewStateNotice compact loading={loading} title={t("documentsLoadFailed")} error={error ? { ...error, message: t("documentsLoadFailed") } : null} onRetry={refresh} />
{current && result ? <>
<Typography role="status" variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("documentsResultCount", { shown: items.length, total: result.total })}</Typography>