通知タイミングの追加・編集をモーダルに
This commit is contained in:
@@ -229,7 +229,10 @@ pnpm monitor:worker
|
||||
- 証明書情報として発行元、発行日時、失効日時を表示
|
||||
- 通知タイミング設定
|
||||
- 時間 / 日 / 週間の単位指定
|
||||
- 通知タイミング未設定時の空状態表示
|
||||
- モーダルでの通知タイミング追加・編集
|
||||
- 複数タイミングの追加・確認ダイアログ付き削除
|
||||
- 通知タイミング0件での設定保存
|
||||
- アプリ内アラート必須表示
|
||||
- Webhook 選択
|
||||
- プッシュ通知フラグ設定
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { ConfirmDialog } from '../components/ConfirmDialog.jsx';
|
||||
import { Field } from '../components/Field.jsx';
|
||||
@@ -22,7 +23,9 @@ function toDisplayThreshold(thresholdHours) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -32,10 +35,22 @@ function formatCertificateValue(value) {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatCondition(condition) {
|
||||
const unitLabels = {
|
||||
hours: '時間前',
|
||||
days: '日前',
|
||||
weeks: '週間前',
|
||||
};
|
||||
return `${condition.value}${unitLabels[condition.unit]}`;
|
||||
}
|
||||
|
||||
export function SiteSettingsPanel({ site, onBack }) {
|
||||
const [alias, setAlias] = 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 [webhookMethodIds, setWebhookMethodIds] = useState([]);
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
@@ -66,21 +81,65 @@ export function SiteSettingsPanel({ site, onBack }) {
|
||||
);
|
||||
setWebhookMethodIds(data.settings.conditions[0].webhookMethodIds);
|
||||
setPushEnabled(data.settings.conditions[0].pushEnabled);
|
||||
} else {
|
||||
setConditions([]);
|
||||
setWebhookMethodIds([]);
|
||||
setPushEnabled(false);
|
||||
}
|
||||
}
|
||||
loadSettings().catch((err) => showToast({ type: 'error', message: err.message }));
|
||||
}, [showToast, site.siteId]);
|
||||
|
||||
function updateCondition(index, patch) {
|
||||
setConditions((current) =>
|
||||
current.map((condition, currentIndex) =>
|
||||
currentIndex === index ? { ...condition, ...patch } : condition,
|
||||
),
|
||||
);
|
||||
function openAddConditionDialog() {
|
||||
setEditingConditionIndex(null);
|
||||
setTimingForm({ value: '', unit: 'days' });
|
||||
setTimingDialogOpen(true);
|
||||
}
|
||||
|
||||
function addCondition() {
|
||||
setConditions((current) => [...current, { value: 7, unit: 'days' }]);
|
||||
function openEditConditionDialog(index) {
|
||||
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) {
|
||||
@@ -108,6 +167,9 @@ export function SiteSettingsPanel({ site, onBack }) {
|
||||
if (thresholdHours.some((value) => !Number.isInteger(value) || value <= 0 || value > 17520)) {
|
||||
throw new Error('通知タイミングは 1 時間以上、2 年以内で指定してください');
|
||||
}
|
||||
if (new Set(thresholdHours).size !== thresholdHours.length) {
|
||||
throw new Error('同じ通知タイミングは重複して登録できません');
|
||||
}
|
||||
if (nextAlias !== savedAlias) {
|
||||
await request(`/api/sites/${site.siteId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -116,14 +178,20 @@ export function SiteSettingsPanel({ site, onBack }) {
|
||||
setAlias(nextAlias);
|
||||
setSavedAlias(nextAlias);
|
||||
}
|
||||
await request(`/api/sites/${site.siteId}/settings`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
conditions: thresholdHours.map((value) => ({ thresholdHours: value })),
|
||||
webhookMethodIds,
|
||||
pushEnabled,
|
||||
}),
|
||||
});
|
||||
if (thresholdHours.length === 0) {
|
||||
await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' });
|
||||
setWebhookMethodIds([]);
|
||||
setPushEnabled(false);
|
||||
} else {
|
||||
await request(`/api/sites/${site.siteId}/settings`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
conditions: thresholdHours.map((value) => ({ thresholdHours: value })),
|
||||
webhookMethodIds,
|
||||
pushEnabled,
|
||||
}),
|
||||
});
|
||||
}
|
||||
showToast({ type: 'success', message: '設定を保存しました' });
|
||||
} catch (err) {
|
||||
showToast({ type: 'error', message: err.message });
|
||||
@@ -136,7 +204,7 @@ export function SiteSettingsPanel({ site, onBack }) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' });
|
||||
setConditions([{ value: 7, unit: 'days' }]);
|
||||
setConditions([]);
|
||||
setWebhookMethodIds([]);
|
||||
setPushEnabled(false);
|
||||
showToast({ type: 'success', message: '設定を削除しました' });
|
||||
@@ -203,53 +271,103 @@ export function SiteSettingsPanel({ site, onBack }) {
|
||||
</div>
|
||||
|
||||
<div className="condition-list">
|
||||
{conditions.map((condition, index) => (
|
||||
<div className="condition-row" key={index}>
|
||||
<Field label="値">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="17520"
|
||||
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>
|
||||
<option value="days">日前</option>
|
||||
<option value="weeks">週間前</option>
|
||||
</select>
|
||||
</Field>
|
||||
<ConfirmDialog
|
||||
title="通知タイミングを削除"
|
||||
description="この通知タイミングを設定から削除します。"
|
||||
onConfirm={() => removeCondition(index)}
|
||||
disabled={conditions.length === 1}
|
||||
trigger={
|
||||
{conditions.length === 0 ? (
|
||||
<p className="condition-empty">通知タイミングはまだ設定されていません。</p>
|
||||
) : (
|
||||
conditions.map((condition, index) => (
|
||||
<div
|
||||
className="condition-row"
|
||||
key={`${condition.unit}-${condition.value}-${index}`}
|
||||
>
|
||||
<span className="condition-value">{formatCondition(condition)}</span>
|
||||
<div className="condition-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
disabled={conditions.length === 1}
|
||||
aria-label="通知タイミングを削除"
|
||||
title="削除"
|
||||
className="icon-button"
|
||||
onClick={() => openEditConditionDialog(index)}
|
||||
aria-label="通知タイミングを編集"
|
||||
title="編集"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={18} />
|
||||
<Pencil aria-hidden="true" size={18} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<ConfirmDialog
|
||||
title="通知タイミングを削除"
|
||||
description="この通知タイミングを設定から削除します。"
|
||||
onConfirm={() => removeCondition(index)}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
aria-label="通知タイミングを削除"
|
||||
title="削除"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={18} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className="secondary" onClick={addCondition}>
|
||||
<button type="button" className="secondary" onClick={openAddConditionDialog}>
|
||||
<Plus aria-hidden="true" size={18} />
|
||||
タイミングを追加
|
||||
</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 className="panel">
|
||||
|
||||
@@ -625,9 +625,42 @@ select:focus {
|
||||
|
||||
.condition-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 1fr) minmax(140px, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
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 {
|
||||
@@ -1035,10 +1068,15 @@ select:focus {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.condition-row {
|
||||
.condition-row,
|
||||
.timing-dialog-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.timing-dialog-form .dialog-actions {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.certificate-details div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
|
||||
Reference in New Issue
Block a user