./knowledge-base/frontend/src/pages/MaintenancePage.tsx
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
AppBar,
Toolbar,
Typography,
Button,
Container,
Box,
Tabs,
Tab,
TextField,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
CircularProgress,
Alert,
Paper,
Autocomplete,
Chip,
Divider,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
Select,
MenuItem,
FormControlLabel,
Checkbox,
} from "@mui/material";
import {
Home as HomeIcon,
Delete as DeleteIcon,
Add as AddIcon,
Save as SaveIcon,
Edit as EditIcon,
Autorenew as AutorenewIcon,
Cancel as CancelIcon,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import {
fetchPrograms,
createProgram,
deleteProgram,
fetchAllDocuments,
updateDocumentPrograms,
updateDocumentMetadata,
reanalyzeDocument,
deleteDocument,
} from "../api/client";
import type { ProgramMaster, DocumentSummary } from "../types";
import { EraSelector } from "../components/EraSelector";
// タブパネルラッパー
function TabPanel({
children,
value,
index,
}: {
children: React.ReactNode;
value: number;
index: number;
}) {
return (
<Box hidden={value !== index} sx={{ pt: 3 }}>
{value === index && children}
</Box>
);
}
// ============================================================
// タブ1: 番組作成
// ============================================================
function ProgramsTab() {
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
const [snack, setSnack] = useState<{
open: boolean;
msg: string;
severity: "success" | "error";
}>({
open: false,
msg: "",
severity: "success",
});
const {
data: programs = [],
isLoading,
error,
} = useQuery<ProgramMaster[]>({
queryKey: ["programs"],
queryFn: fetchPrograms,
});
const createMutation = useMutation({
mutationFn: (name: string) => createProgram(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["programs"] });
setNewName("");
setSnack({ open: true, msg: "番組を追加しました", severity: "success" });
},
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { message?: string } } }).response?.data
?.message || "追加に失敗しました";
setSnack({ open: true, msg, severity: "error" });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteProgram(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["programs"] });
setSnack({ open: true, msg: "番組を削除しました", severity: "success" });
},
onError: () => {
setSnack({ open: true, msg: "削除に失敗しました", severity: "error" });
},
});
const handleAdd = () => {
const name = newName.trim();
if (!name) return;
createMutation.mutate(name);
};
return (
<Box>
<Typography variant="h6" gutterBottom>
番組作成
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
番組名テーブルに番組を追加・削除できます。
</Typography>
{/* 追加フォーム */}
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
<TextField
label="番組名"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleAdd()}
placeholder="例: 大河ドラマ〇〇"
size="small"
sx={{ flex: 1 }}
/>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAdd}
disabled={!newName.trim() || createMutation.isPending}
>
追加
</Button>
</Box>
</Paper>
{/* 番組一覧 */}
{isLoading && <CircularProgress />}
{error && (
<Alert severity="error">番組一覧の読み込みに失敗しました</Alert>
)}
{!isLoading && programs.length === 0 && (
<Typography color="text.secondary">番組が登録されていません</Typography>
)}
<List dense>
{programs.map((program, idx) => (
<Box key={program.id}>
{idx > 0 && <Divider />}
<ListItem>
<ListItemText primary={program.name} />
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="削除"
onClick={() => {
if (window.confirm(`「${program.name}」を削除しますか?`)) {
deleteMutation.mutate(program.id);
}
}}
disabled={deleteMutation.isPending}
size="small"
color="error"
>
<DeleteIcon fontSize="small" />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
<Snackbar
open={snack.open}
autoHideDuration={3000}
onClose={() => setSnack((s) => ({ ...s, open: false }))}
>
<Alert
severity={snack.severity}
onClose={() => setSnack((s) => ({ ...s, open: false }))}
>
{snack.msg}
</Alert>
</Snackbar>
</Box>
);
}
// ============================================================
// タブ2: 番組紐づけ
// ============================================================
// 1資料分の番組紐づけ行
function DocumentProgramRow({
doc,
programs,
onSaved,
}: {
doc: DocumentSummary;
programs: ProgramMaster[];
onSaved: (msg: string, ok: boolean) => void;
}) {
const [selected, setSelected] = useState<string[]>(doc.programs ?? []);
const [dirty, setDirty] = useState(false);
const saveMutation = useMutation({
mutationFn: () => updateDocumentPrograms(doc.document_id, selected),
onSuccess: () => {
setDirty(false);
onSaved("保存しました", true);
},
onError: () => {
onSaved("保存に失敗しました", false);
},
});
return (
<Paper variant="outlined" sx={{ p: 2, mb: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
{/* タイトル */}
<Typography
variant="subtitle2"
fontWeight="bold"
sx={{ minWidth: 200, flex: "0 0 auto" }}
>
{doc.title || "(タイトル未設定)"}
</Typography>
{/* 番組マルチ選択 */}
<Autocomplete
multiple
disableCloseOnSelect
options={programs.map((p) => p.name)}
value={selected}
onChange={(_, newValue) => {
setSelected(newValue);
setDirty(true);
}}
renderInput={(params) => (
<TextField
{...params}
label="番組名"
size="small"
placeholder="選択してください"
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={option}
size="small"
{...getTagProps({ index })}
key={option}
/>
))
}
sx={{ flex: 1, minWidth: 250 }}
size="small"
/>
{/* 保存ボタン */}
<Button
variant={dirty ? "contained" : "outlined"}
size="small"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !dirty}
sx={{ whiteSpace: "nowrap" }}
>
{saveMutation.isPending ? "保存中..." : "保存"}
</Button>
</Box>
</Paper>
);
}
function DocumentBindingTab() {
const [snack, setSnack] = useState<{
open: boolean;
msg: string;
severity: "success" | "error";
}>({
open: false,
msg: "",
severity: "success",
});
const {
data: documents = [],
isLoading: docsLoading,
error: docsError,
} = useQuery<DocumentSummary[]>({
queryKey: ["maintenance-documents"],
queryFn: fetchAllDocuments,
});
const { data: programs = [], isLoading: progsLoading } = useQuery<
ProgramMaster[]
>({
queryKey: ["programs"],
queryFn: fetchPrograms,
});
const loading = docsLoading || progsLoading;
return (
<Box>
<Typography variant="h6" gutterBottom>
番組紐づけ
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
各資料タイトルに番組名を紐づけます。同じタイトルの全ページに同一の番組名が設定されます。
</Typography>
{loading && <CircularProgress />}
{docsError && (
<Alert severity="error">資料一覧の読み込みに失敗しました</Alert>
)}
{!loading && documents.length === 0 && (
<Typography color="text.secondary">資料がありません</Typography>
)}
{!loading &&
documents.map((doc) => (
<DocumentProgramRow
key={doc.document_id}
doc={doc}
programs={programs}
onSaved={(msg, ok) =>
setSnack({ open: true, msg, severity: ok ? "success" : "error" })
}
/>
))}
<Snackbar
open={snack.open}
autoHideDuration={3000}
onClose={() => setSnack((s) => ({ ...s, open: false }))}
>
<Alert
severity={snack.severity}
onClose={() => setSnack((s) => ({ ...s, open: false }))}
>
{snack.msg}
</Alert>
</Snackbar>
</Box>
);
}
// ============================================================
// タブ3: 書籍のメタ情報編集
// ============================================================
const DOC_GENRE_OPTIONS = [
"写真集",
"ノンフィクション小説",
"辞書",
"年表",
"メール・議事録",
];
type EditState = {
title: string;
document_genre: string;
programs: string[];
era_labels: string[];