通知タイミングの追加・編集をモーダルに

This commit is contained in:
CyberRex
2026-05-27 09:06:00 +09:00
parent a0356e630e
commit e89f7b4cf3
3 changed files with 220 additions and 61 deletions

View File

@@ -229,7 +229,10 @@ pnpm monitor:worker
- 証明書情報として発行元、発行日時、失効日時を表示 - 証明書情報として発行元、発行日時、失効日時を表示
- 通知タイミング設定 - 通知タイミング設定
- 時間 / 日 / 週間の単位指定 - 時間 / 日 / 週間の単位指定
- 通知タイミング未設定時の空状態表示
- モーダルでの通知タイミング追加・編集
- 複数タイミングの追加・確認ダイアログ付き削除 - 複数タイミングの追加・確認ダイアログ付き削除
- 通知タイミング0件での設定保存
- アプリ内アラート必須表示 - アプリ内アラート必須表示
- Webhook 選択 - Webhook 選択
- プッシュ通知フラグ設定 - プッシュ通知フラグ設定

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Bell, Plus, Save, Trash2 } from 'lucide-react'; import * as Dialog from '@radix-ui/react-dialog';
import { ArrowLeft, Bell, Pencil, Plus, Save, Trash2 } from 'lucide-react';
import { request } from '../api/client.js'; import { request } from '../api/client.js';
import { ConfirmDialog } from '../components/ConfirmDialog.jsx'; import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
import { Field } from '../components/Field.jsx'; import { Field } from '../components/Field.jsx';
@@ -22,7 +23,9 @@ function toDisplayThreshold(thresholdHours) {
} }
function toThresholdHours(condition) { function toThresholdHours(condition) {
return Number.parseInt(condition.value, 10) * unitToHours[condition.unit]; const value = String(condition.value).trim();
if (!/^\d+$/.test(value)) return Number.NaN;
return Number.parseInt(value, 10) * unitToHours[condition.unit];
} }
function formatCertificateValue(value) { function formatCertificateValue(value) {
@@ -32,10 +35,22 @@ function formatCertificateValue(value) {
return date.toLocaleString(); return date.toLocaleString();
} }
function formatCondition(condition) {
const unitLabels = {
hours: '時間前',
days: '日前',
weeks: '週間前',
};
return `${condition.value}${unitLabels[condition.unit]}`;
}
export function SiteSettingsPanel({ site, onBack }) { export function SiteSettingsPanel({ site, onBack }) {
const [alias, setAlias] = useState(site.alias); const [alias, setAlias] = useState(site.alias);
const [savedAlias, setSavedAlias] = useState(site.alias); const [savedAlias, setSavedAlias] = useState(site.alias);
const [conditions, setConditions] = useState([{ value: 7, unit: 'days' }]); const [conditions, setConditions] = useState([]);
const [timingDialogOpen, setTimingDialogOpen] = useState(false);
const [editingConditionIndex, setEditingConditionIndex] = useState(null);
const [timingForm, setTimingForm] = useState({ value: '', unit: 'days' });
const [availableWebhooks, setAvailableWebhooks] = useState([]); const [availableWebhooks, setAvailableWebhooks] = useState([]);
const [webhookMethodIds, setWebhookMethodIds] = useState([]); const [webhookMethodIds, setWebhookMethodIds] = useState([]);
const [pushEnabled, setPushEnabled] = useState(false); const [pushEnabled, setPushEnabled] = useState(false);
@@ -66,21 +81,65 @@ export function SiteSettingsPanel({ site, onBack }) {
); );
setWebhookMethodIds(data.settings.conditions[0].webhookMethodIds); setWebhookMethodIds(data.settings.conditions[0].webhookMethodIds);
setPushEnabled(data.settings.conditions[0].pushEnabled); setPushEnabled(data.settings.conditions[0].pushEnabled);
} else {
setConditions([]);
setWebhookMethodIds([]);
setPushEnabled(false);
} }
} }
loadSettings().catch((err) => showToast({ type: 'error', message: err.message })); loadSettings().catch((err) => showToast({ type: 'error', message: err.message }));
}, [showToast, site.siteId]); }, [showToast, site.siteId]);
function updateCondition(index, patch) { function openAddConditionDialog() {
setConditions((current) => setEditingConditionIndex(null);
current.map((condition, currentIndex) => setTimingForm({ value: '', unit: 'days' });
currentIndex === index ? { ...condition, ...patch } : condition, setTimingDialogOpen(true);
),
);
} }
function addCondition() { function openEditConditionDialog(index) {
setConditions((current) => [...current, { value: 7, unit: 'days' }]); setEditingConditionIndex(index);
setTimingForm({
value: String(conditions[index].value),
unit: conditions[index].unit,
});
setTimingDialogOpen(true);
}
function saveTimingCondition(event) {
event.preventDefault();
event.stopPropagation();
const thresholdHours = toThresholdHours(timingForm);
if (!Number.isInteger(thresholdHours) || thresholdHours <= 0 || thresholdHours > 17520) {
showToast({
type: 'error',
message: '通知タイミングは 1 時間以上、2 年以内で指定してください',
});
return;
}
const duplicated = conditions.some((condition, index) => {
if (index === editingConditionIndex) return false;
return toThresholdHours(condition) === thresholdHours;
});
if (duplicated) {
showToast({ type: 'error', message: '同じ通知タイミングは重複して登録できません' });
return;
}
setConditions((current) => {
const nextCondition = {
value: Number.parseInt(String(timingForm.value).trim(), 10),
unit: timingForm.unit,
};
if (editingConditionIndex === null) {
return [...current, nextCondition];
}
return current.map((condition, index) =>
index === editingConditionIndex ? nextCondition : condition,
);
});
setTimingDialogOpen(false);
} }
function removeCondition(index) { function removeCondition(index) {
@@ -108,6 +167,9 @@ export function SiteSettingsPanel({ site, onBack }) {
if (thresholdHours.some((value) => !Number.isInteger(value) || value <= 0 || value > 17520)) { if (thresholdHours.some((value) => !Number.isInteger(value) || value <= 0 || value > 17520)) {
throw new Error('通知タイミングは 1 時間以上、2 年以内で指定してください'); throw new Error('通知タイミングは 1 時間以上、2 年以内で指定してください');
} }
if (new Set(thresholdHours).size !== thresholdHours.length) {
throw new Error('同じ通知タイミングは重複して登録できません');
}
if (nextAlias !== savedAlias) { if (nextAlias !== savedAlias) {
await request(`/api/sites/${site.siteId}`, { await request(`/api/sites/${site.siteId}`, {
method: 'PATCH', method: 'PATCH',
@@ -116,6 +178,11 @@ export function SiteSettingsPanel({ site, onBack }) {
setAlias(nextAlias); setAlias(nextAlias);
setSavedAlias(nextAlias); setSavedAlias(nextAlias);
} }
if (thresholdHours.length === 0) {
await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' });
setWebhookMethodIds([]);
setPushEnabled(false);
} else {
await request(`/api/sites/${site.siteId}/settings`, { await request(`/api/sites/${site.siteId}/settings`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
@@ -124,6 +191,7 @@ export function SiteSettingsPanel({ site, onBack }) {
pushEnabled, pushEnabled,
}), }),
}); });
}
showToast({ type: 'success', message: '設定を保存しました' }); showToast({ type: 'success', message: '設定を保存しました' });
} catch (err) { } catch (err) {
showToast({ type: 'error', message: err.message }); showToast({ type: 'error', message: err.message });
@@ -136,7 +204,7 @@ export function SiteSettingsPanel({ site, onBack }) {
setBusy(true); setBusy(true);
try { try {
await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' }); await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' });
setConditions([{ value: 7, unit: 'days' }]); setConditions([]);
setWebhookMethodIds([]); setWebhookMethodIds([]);
setPushEnabled(false); setPushEnabled(false);
showToast({ type: 'success', message: '設定を削除しました' }); showToast({ type: 'success', message: '設定を削除しました' });
@@ -203,38 +271,33 @@ export function SiteSettingsPanel({ site, onBack }) {
</div> </div>
<div className="condition-list"> <div className="condition-list">
{conditions.map((condition, index) => ( {conditions.length === 0 ? (
<div className="condition-row" key={index}> <p className="condition-empty">通知タイミングはまだ設定されていません</p>
<Field label="値"> ) : (
<input conditions.map((condition, index) => (
type="number" <div
min="1" className="condition-row"
max="17520" key={`${condition.unit}-${condition.value}-${index}`}
value={condition.value}
onChange={(event) => updateCondition(index, { value: event.target.value })}
required
/>
</Field>
<Field label="単位">
<select
value={condition.unit}
onChange={(event) => updateCondition(index, { unit: event.target.value })}
> >
<option value="hours">時間前</option> <span className="condition-value">{formatCondition(condition)}</span>
<option value="days">日前</option> <div className="condition-actions">
<option value="weeks">週間前</option> <button
</select> type="button"
</Field> className="icon-button"
onClick={() => openEditConditionDialog(index)}
aria-label="通知タイミングを編集"
title="編集"
>
<Pencil aria-hidden="true" size={18} />
</button>
<ConfirmDialog <ConfirmDialog
title="通知タイミングを削除" title="通知タイミングを削除"
description="この通知タイミングを設定から削除します。" description="この通知タイミングを設定から削除します。"
onConfirm={() => removeCondition(index)} onConfirm={() => removeCondition(index)}
disabled={conditions.length === 1}
trigger={ trigger={
<button <button
type="button" type="button"
className="icon-button danger" className="icon-button danger"
disabled={conditions.length === 1}
aria-label="通知タイミングを削除" aria-label="通知タイミングを削除"
title="削除" title="削除"
> >
@@ -243,13 +306,68 @@ export function SiteSettingsPanel({ site, onBack }) {
} }
/> />
</div> </div>
))} </div>
))
)}
</div> </div>
<button type="button" className="secondary" onClick={addCondition}> <button type="button" className="secondary" onClick={openAddConditionDialog}>
<Plus aria-hidden="true" size={18} /> <Plus aria-hidden="true" size={18} />
タイミングを追加 タイミングを追加
</button> </button>
<Dialog.Root open={timingDialogOpen} onOpenChange={setTimingDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<Dialog.Title className="dialog-title">
{editingConditionIndex === null
? '通知タイミングを追加'
: '通知タイミングを編集'}
</Dialog.Title>
<Dialog.Description className="dialog-description">
証明書の失効前に通知するタイミングを指定します
</Dialog.Description>
<form className="dialog-form timing-dialog-form" onSubmit={saveTimingCondition}>
<Field label="値">
<input
type="number"
min="1"
max="17520"
value={timingForm.value}
onChange={(event) =>
setTimingForm({ ...timingForm, value: event.target.value })
}
placeholder="例: 7"
required
/>
</Field>
<Field label="単位">
<select
value={timingForm.unit}
onChange={(event) =>
setTimingForm({ ...timingForm, unit: event.target.value })
}
>
<option value="hours">時間前</option>
<option value="days">日前</option>
<option value="weeks">週間前</option>
</select>
</Field>
<div className="dialog-actions">
<Dialog.Close asChild>
<button className="secondary" type="button">
キャンセル
</button>
</Dialog.Close>
<button className="primary" type="submit">
{editingConditionIndex === null ? '追加' : '更新'}
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</section> </section>
<section className="panel"> <section className="panel">

View File

@@ -625,9 +625,42 @@ select:focus {
.condition-row { .condition-row {
display: grid; display: grid;
grid-template-columns: minmax(100px, 1fr) minmax(140px, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 12px; gap: 12px;
align-items: end; align-items: center;
border: 1px solid #d9e1de;
border-radius: 8px;
padding: 12px;
}
.condition-empty {
margin: 0;
border: 1px dashed #b9c7c2;
border-radius: 8px;
padding: 14px;
color: #5a6a65;
background: #f8fbfa;
}
.condition-value {
min-width: 0;
color: #17201d;
font-weight: 800;
}
.condition-actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.timing-dialog-form {
grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr);
}
.timing-dialog-form .dialog-actions {
grid-column: 1 / -1;
} }
.check-row { .check-row {
@@ -1035,10 +1068,15 @@ select:focus {
max-width: 100%; max-width: 100%;
} }
.condition-row { .condition-row,
.timing-dialog-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.timing-dialog-form .dialog-actions {
grid-column: auto;
}
.certificate-details div { .certificate-details div {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 4px; gap: 4px;