Initial commit
This commit is contained in:
192
resources/js/components/admin/news/AdminNewsForm.tsx
Normal file
192
resources/js/components/admin/news/AdminNewsForm.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input, Switch, Textarea } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type NewsItem, type NewsPayload, type FormMode, type NewsTranslation } from "./adminNewsTypes";
|
||||
|
||||
const resolveLocale = (field: NewsTranslation | null | undefined, lang: string) => {
|
||||
if (!field) return "";
|
||||
if (typeof field === "string") return field;
|
||||
return field[lang] ?? field["en"] ?? Object.values(field)[0] ?? "";
|
||||
};
|
||||
|
||||
const formatForDateTimeLocal = (value: string | null | undefined) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
|
||||
const pad = (num: number) => num.toString().padStart(2, "0");
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hours = pad(date.getHours());
|
||||
const minutes = pad(date.getMinutes());
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
type AdminNewsFormProps = {
|
||||
mode: Exclude<FormMode, "none">;
|
||||
editing: NewsItem | null;
|
||||
onSubmit: (payload: NewsPayload) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
submitting: boolean;
|
||||
serverError: string | null;
|
||||
};
|
||||
|
||||
export default function AdminNewsForm({
|
||||
mode,
|
||||
editing,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitting,
|
||||
serverError,
|
||||
}: AdminNewsFormProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [titleCs, setTitleCs] = useState("");
|
||||
const [titleEn, setTitleEn] = useState("");
|
||||
const [contentCs, setContentCs] = useState("");
|
||||
const [contentEn, setContentEn] = useState("");
|
||||
const [excerptCs, setExcerptCs] = useState("");
|
||||
const [excerptEn, setExcerptEn] = useState("");
|
||||
const [publishedAt, setPublishedAt] = useState("");
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && editing) {
|
||||
setTitleCs(resolveLocale(editing.title, "cs"));
|
||||
setTitleEn(resolveLocale(editing.title, "en"));
|
||||
setExcerptCs(resolveLocale(editing.excerpt, "cs"));
|
||||
setExcerptEn(resolveLocale(editing.excerpt, "en"));
|
||||
setContentCs(resolveLocale(editing.content, "cs"));
|
||||
setContentEn(resolveLocale(editing.content, "en"));
|
||||
setPublishedAt(formatForDateTimeLocal(editing.published_at));
|
||||
setIsPublished(!!editing.is_published);
|
||||
setLocalError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTitleCs("");
|
||||
setTitleEn("");
|
||||
setContentCs("");
|
||||
setContentEn("");
|
||||
setExcerptCs("");
|
||||
setExcerptEn("");
|
||||
setPublishedAt("");
|
||||
setIsPublished(false);
|
||||
setLocalError(null);
|
||||
}, [mode, editing]);
|
||||
|
||||
const submitLabel = useMemo(() => {
|
||||
if (mode === "edit") return t("admin_news_form_save") ?? "Uložit změny";
|
||||
return t("admin_news_form_create") ?? "Vytvořit novinku";
|
||||
}, [mode, t]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setLocalError(null);
|
||||
|
||||
const titlePayload: Record<string, string> = {};
|
||||
if (titleCs.trim()) titlePayload.cs = titleCs.trim();
|
||||
if (titleEn.trim()) titlePayload.en = titleEn.trim();
|
||||
if (Object.keys(titlePayload).length === 0) {
|
||||
setLocalError(t("admin_news_title_required") ?? "Vyplň nadpis alespoň v jednom jazyce.");
|
||||
return;
|
||||
}
|
||||
|
||||
const contentPayload: Record<string, string> = {};
|
||||
if (contentCs.trim()) contentPayload.cs = contentCs.trim();
|
||||
if (contentEn.trim()) contentPayload.en = contentEn.trim();
|
||||
if (Object.keys(contentPayload).length === 0) {
|
||||
setLocalError(t("admin_news_content_required") ?? "Vyplň obsah alespoň v jednom jazyce.");
|
||||
return;
|
||||
}
|
||||
|
||||
const excerptPayload: Record<string, string> = {};
|
||||
if (excerptCs.trim()) excerptPayload.cs = excerptCs.trim();
|
||||
if (excerptEn.trim()) excerptPayload.en = excerptEn.trim();
|
||||
|
||||
const payload: NewsPayload = {
|
||||
title: titlePayload,
|
||||
content: contentPayload,
|
||||
is_published: isPublished,
|
||||
};
|
||||
|
||||
if (Object.keys(excerptPayload).length > 0) payload.excerpt = excerptPayload;
|
||||
if (publishedAt.trim()) payload.published_at = publishedAt.trim();
|
||||
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
label={t("admin_news_form_title_cs") ?? "Nadpis (cs)"}
|
||||
value={titleCs}
|
||||
onChange={(e) => setTitleCs(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t("admin_news_form_title_en") ?? "Title (en)"}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Textarea
|
||||
label={t("admin_news_form_excerpt_cs") ?? "Perex (cs)"}
|
||||
value={excerptCs}
|
||||
onChange={(e) => setExcerptCs(e.target.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("admin_news_form_excerpt_en") ?? "Excerpt (en)"}
|
||||
value={excerptEn}
|
||||
onChange={(e) => setExcerptEn(e.target.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Textarea
|
||||
label={t("admin_news_form_content_cs") ?? "Obsah (cs)"}
|
||||
value={contentCs}
|
||||
onChange={(e) => setContentCs(e.target.value)}
|
||||
minRows={4}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("admin_news_form_content_en") ?? "Content (en)"}
|
||||
value={contentEn}
|
||||
onChange={(e) => setContentEn(e.target.value)}
|
||||
minRows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 items-center">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
label={t("admin_news_form_published_from") ?? "Publikováno od"}
|
||||
value={publishedAt}
|
||||
onChange={(e) => setPublishedAt(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch isSelected={isPublished} onValueChange={setIsPublished}>
|
||||
{t("admin_news_form_publish") ?? "Publikovat"}
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(localError || serverError) && (
|
||||
<div className="text-sm text-red-600">{localError ?? serverError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" color="primary" isLoading={submitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
<Button type="button" variant="light" onPress={onCancel}>
|
||||
{t("admin_form_close") ?? "Zavřít formulář"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
112
resources/js/components/admin/news/AdminNewsTable.tsx
Normal file
112
resources/js/components/admin/news/AdminNewsTable.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type NewsItem, type NewsTranslation } from "./adminNewsTypes";
|
||||
|
||||
const resolveLocale = (field: NewsTranslation | null | undefined, lang: string) => {
|
||||
if (!field) return "";
|
||||
if (typeof field === "string") return field;
|
||||
return field[lang] ?? field["en"] ?? Object.values(field)[0] ?? "";
|
||||
};
|
||||
|
||||
const formatDate = (value: string | null | undefined, lang: string) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(lang, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
type AdminNewsTableProps = {
|
||||
items: NewsItem[];
|
||||
locale: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onEdit: (item: NewsItem) => void;
|
||||
};
|
||||
|
||||
export default function AdminNewsTable({
|
||||
items,
|
||||
locale,
|
||||
loading,
|
||||
error,
|
||||
onEdit,
|
||||
}: AdminNewsTableProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const valuePlaceholder = t("value_na") ?? "—";
|
||||
|
||||
if (loading) {
|
||||
return <div>{t("admin_news_loading") ?? "Načítám novinky…"}</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-sm text-red-600">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
aria-label={t("admin_news_table_aria") ?? "Admin news table"}
|
||||
selectionMode="none"
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>{t("admin_news_title_cs") ?? "Název (cs)"}</TableColumn>
|
||||
<TableColumn>{t("admin_news_title_en") ?? "Title (en)"}</TableColumn>
|
||||
<TableColumn>{t("admin_news_published_at") ?? "Publikováno"}</TableColumn>
|
||||
<TableColumn>{t("admin_news_published_flag") ?? "Zveřejněno"}</TableColumn>
|
||||
<TableColumn></TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={items}>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{resolveLocale(item.title, "cs")}</TableCell>
|
||||
<TableCell>{resolveLocale(item.title, "en")}</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
if (!item.published_at) return valuePlaceholder;
|
||||
const date = new Date(item.published_at);
|
||||
if (Number.isNaN(date.getTime())) return valuePlaceholder;
|
||||
const isFuture = date.getTime() > Date.now();
|
||||
const className = isFuture ? "italic" : "font-semibold";
|
||||
return (
|
||||
<span className={className}>
|
||||
{formatDate(item.published_at, locale)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.is_published ? t("yes") ?? "Ano" : t("no") ?? "Ne"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(item)}
|
||||
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
|
||||
aria-label={t("admin_news_edit_aria") ?? "Upravit novinku"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
|
||||
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
21
resources/js/components/admin/news/adminNewsTypes.ts
Normal file
21
resources/js/components/admin/news/adminNewsTypes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type NewsTranslation = string | Record<string, string>;
|
||||
|
||||
export type NewsItem = {
|
||||
id: number;
|
||||
title: NewsTranslation;
|
||||
excerpt: NewsTranslation | null;
|
||||
content: NewsTranslation;
|
||||
slug: string;
|
||||
published_at: string | null;
|
||||
is_published: boolean;
|
||||
};
|
||||
|
||||
export type NewsPayload = {
|
||||
title: Record<string, string>;
|
||||
content: Record<string, string>;
|
||||
excerpt?: Record<string, string>;
|
||||
published_at?: string;
|
||||
is_published: boolean;
|
||||
};
|
||||
|
||||
export type FormMode = "none" | "create" | "edit";
|
||||
1325
resources/js/components/admin/rulesets/AdminRulesetForm.tsx
Normal file
1325
resources/js/components/admin/rulesets/AdminRulesetForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@heroui/react";
|
||||
import type { EvaluationRuleSet } from "./adminRulesetTypes";
|
||||
|
||||
type AdminRulesetsTableProps = {
|
||||
items: EvaluationRuleSet[];
|
||||
locale: string;
|
||||
valuePlaceholder: string;
|
||||
formatDate: (value: string | null | undefined, lang: string) => string;
|
||||
onEdit: (item: EvaluationRuleSet) => void;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
const PencilIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
|
||||
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AdminRulesetsTable({
|
||||
items,
|
||||
locale,
|
||||
valuePlaceholder,
|
||||
formatDate,
|
||||
onEdit,
|
||||
t,
|
||||
}: AdminRulesetsTableProps) {
|
||||
return (
|
||||
<Table aria-label={t("admin_rulesets_table_aria") ?? "Evaluation rulesets table"} selectionMode="none">
|
||||
<TableHeader>
|
||||
<TableColumn>{t("admin_rulesets_table_name") ?? "Název"}</TableColumn>
|
||||
<TableColumn>{t("admin_rulesets_table_code") ?? "Kód"}</TableColumn>
|
||||
<TableColumn>{t("admin_rulesets_table_scoring") ?? "Scoring"}</TableColumn>
|
||||
<TableColumn>{t("admin_rulesets_table_multiplier") ?? "Multiplier"}</TableColumn>
|
||||
<TableColumn>{t("admin_rulesets_table_updated") ?? "Aktualizace"}</TableColumn>
|
||||
<TableColumn></TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={items}>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.code}</TableCell>
|
||||
<TableCell>{item.scoring_mode ?? valuePlaceholder}</TableCell>
|
||||
<TableCell>{item.multiplier_type ?? valuePlaceholder}</TableCell>
|
||||
<TableCell>{formatDate(item.updated_at ?? null, locale)}</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(item)}
|
||||
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
|
||||
aria-label={t("admin_rulesets_edit_aria") ?? "Upravit rule set"}
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
259
resources/js/components/admin/rulesets/adminRulesetTypes.ts
Normal file
259
resources/js/components/admin/rulesets/adminRulesetTypes.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
export type EvaluationRuleSet = {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string | null;
|
||||
scoring_mode?: string | null;
|
||||
points_per_qso?: number | null;
|
||||
points_per_km?: number | null;
|
||||
use_multipliers?: boolean | null;
|
||||
multiplier_type?: string | null;
|
||||
dup_qso_policy?: string | null;
|
||||
nil_qso_policy?: string | null;
|
||||
no_counterpart_log_policy?: string | null;
|
||||
not_in_counterpart_log_policy?: string | null;
|
||||
unique_qso_policy?: string | null;
|
||||
busted_call_policy?: string | null;
|
||||
busted_rst_policy?: string | null;
|
||||
busted_exchange_policy?: string | null;
|
||||
busted_serial_policy?: string | null;
|
||||
busted_locator_policy?: string | null;
|
||||
penalty_dup_points?: number | null;
|
||||
penalty_nil_points?: number | null;
|
||||
penalty_busted_call_points?: number | null;
|
||||
penalty_busted_rst_points?: number | null;
|
||||
penalty_busted_exchange_points?: number | null;
|
||||
penalty_busted_serial_points?: number | null;
|
||||
penalty_busted_locator_points?: number | null;
|
||||
penalty_out_of_window_points?: number | null;
|
||||
dupe_scope?: string | null;
|
||||
callsign_normalization?: string | null;
|
||||
distance_rounding?: string | null;
|
||||
min_distance_km?: number | null;
|
||||
require_locators?: boolean | null;
|
||||
out_of_window_policy?: string | null;
|
||||
exchange_type?: string | null;
|
||||
exchange_requires_wwl?: boolean | null;
|
||||
exchange_requires_serial?: boolean | null;
|
||||
exchange_requires_report?: boolean | null;
|
||||
exchange_pattern?: string | null;
|
||||
match_tiebreak_order?: string[] | null;
|
||||
match_require_locator_match?: boolean | null;
|
||||
match_require_exchange_match?: boolean | null;
|
||||
multiplier_scope?: string | null;
|
||||
multiplier_source?: string | null;
|
||||
wwl_multiplier_level?: string | null;
|
||||
checklog_matching?: boolean | null;
|
||||
out_of_window_dq_threshold?: number | null;
|
||||
time_diff_dq_threshold_percent?: number | null;
|
||||
time_diff_dq_threshold_sec?: number | null;
|
||||
bad_qso_dq_threshold_percent?: number | null;
|
||||
time_tolerance_sec?: number | null;
|
||||
allow_time_shift_one_hour?: boolean | null;
|
||||
time_shift_seconds?: number | null;
|
||||
time_mismatch_policy?: string | null;
|
||||
allow_time_mismatch_pairing?: boolean | null;
|
||||
time_mismatch_max_sec?: number | null;
|
||||
require_unique_qso?: boolean | null;
|
||||
ignore_slash_part?: boolean | null;
|
||||
ignore_third_part?: boolean | null;
|
||||
rst_ignore_third_char?: boolean | null;
|
||||
callsign_suffix_max_len?: number | null;
|
||||
callsign_levenshtein_max?: number | null;
|
||||
letters_in_rst?: boolean | null;
|
||||
discard_qso_rec_diff_call?: boolean | null;
|
||||
discard_qso_sent_diff_call?: boolean | null;
|
||||
discard_qso_rec_diff_rst?: boolean | null;
|
||||
discard_qso_sent_diff_rst?: boolean | null;
|
||||
discard_qso_rec_diff_serial?: boolean | null;
|
||||
discard_qso_sent_diff_serial?: boolean | null;
|
||||
discard_qso_rec_diff_wwl?: boolean | null;
|
||||
discard_qso_sent_diff_wwl?: boolean | null;
|
||||
discard_qso_rec_diff_code?: boolean | null;
|
||||
discard_qso_sent_diff_code?: boolean | null;
|
||||
dup_resolution_strategy?: string[] | null;
|
||||
operating_window_mode?: string | null;
|
||||
operating_window_hours?: number | null;
|
||||
sixhr_ranking_mode?: string | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type RuleSetForm = {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
scoring_mode: string;
|
||||
points_per_qso: string;
|
||||
points_per_km: string;
|
||||
use_multipliers: boolean;
|
||||
multiplier_type: string;
|
||||
dup_qso_policy: string;
|
||||
nil_qso_policy: string;
|
||||
no_counterpart_log_policy: string;
|
||||
not_in_counterpart_log_policy: string;
|
||||
unique_qso_policy: string;
|
||||
busted_call_policy: string;
|
||||
busted_rst_policy: string;
|
||||
busted_exchange_policy: string;
|
||||
busted_serial_policy: string;
|
||||
busted_locator_policy: string;
|
||||
penalty_dup_points: string;
|
||||
penalty_nil_points: string;
|
||||
penalty_busted_call_points: string;
|
||||
penalty_busted_rst_points: string;
|
||||
penalty_busted_exchange_points: string;
|
||||
penalty_busted_serial_points: string;
|
||||
penalty_busted_locator_points: string;
|
||||
penalty_out_of_window_points: string;
|
||||
dupe_scope: string;
|
||||
callsign_normalization: string;
|
||||
distance_rounding: string;
|
||||
min_distance_km: string;
|
||||
require_locators: boolean;
|
||||
out_of_window_policy: string;
|
||||
exchange_type: string;
|
||||
exchange_requires_wwl: boolean;
|
||||
exchange_requires_serial: boolean;
|
||||
exchange_requires_report: boolean;
|
||||
exchange_pattern: string;
|
||||
match_tiebreak_order: string;
|
||||
match_require_locator_match: boolean;
|
||||
match_require_exchange_match: boolean;
|
||||
multiplier_scope: string;
|
||||
multiplier_source: string;
|
||||
wwl_multiplier_level: string;
|
||||
checklog_matching: boolean;
|
||||
out_of_window_dq_threshold: string;
|
||||
time_diff_dq_threshold_percent: string;
|
||||
time_diff_dq_threshold_sec: string;
|
||||
bad_qso_dq_threshold_percent: string;
|
||||
time_tolerance_sec: string;
|
||||
allow_time_shift_one_hour: boolean;
|
||||
time_shift_seconds: string;
|
||||
time_mismatch_policy: string;
|
||||
allow_time_mismatch_pairing: boolean;
|
||||
time_mismatch_max_sec: string;
|
||||
require_unique_qso: boolean;
|
||||
ignore_slash_part: boolean;
|
||||
ignore_third_part: boolean;
|
||||
rst_ignore_third_char: boolean;
|
||||
callsign_suffix_max_len: string;
|
||||
callsign_levenshtein_max: string;
|
||||
letters_in_rst: boolean;
|
||||
discard_qso_rec_diff_call: boolean;
|
||||
discard_qso_sent_diff_call: boolean;
|
||||
discard_qso_rec_diff_rst: boolean;
|
||||
discard_qso_sent_diff_rst: boolean;
|
||||
discard_qso_rec_diff_serial: boolean;
|
||||
discard_qso_sent_diff_serial: boolean;
|
||||
discard_qso_rec_diff_wwl: boolean;
|
||||
discard_qso_sent_diff_wwl: boolean;
|
||||
discard_qso_rec_diff_code: boolean;
|
||||
discard_qso_sent_diff_code: boolean;
|
||||
dup_resolution_strategy: string;
|
||||
operating_window_mode: string;
|
||||
operating_window_hours: string;
|
||||
sixhr_ranking_mode: string;
|
||||
};
|
||||
|
||||
export type RuleSetFormMode = "none" | "create" | "edit";
|
||||
|
||||
export const emptyForm: RuleSetForm = {
|
||||
name: "",
|
||||
code: "",
|
||||
description: "",
|
||||
scoring_mode: "DISTANCE",
|
||||
points_per_qso: "",
|
||||
points_per_km: "",
|
||||
use_multipliers: true,
|
||||
multiplier_type: "WWL",
|
||||
dup_qso_policy: "ZERO_POINTS",
|
||||
nil_qso_policy: "PENALTY",
|
||||
no_counterpart_log_policy: "PENALTY",
|
||||
not_in_counterpart_log_policy: "PENALTY",
|
||||
unique_qso_policy: "ZERO_POINTS",
|
||||
busted_call_policy: "PENALTY",
|
||||
busted_rst_policy: "ZERO_POINTS",
|
||||
busted_exchange_policy: "ZERO_POINTS",
|
||||
busted_serial_policy: "ZERO_POINTS",
|
||||
busted_locator_policy: "ZERO_POINTS",
|
||||
penalty_dup_points: "",
|
||||
penalty_nil_points: "",
|
||||
penalty_busted_call_points: "",
|
||||
penalty_busted_rst_points: "",
|
||||
penalty_busted_exchange_points: "",
|
||||
penalty_busted_serial_points: "",
|
||||
penalty_busted_locator_points: "",
|
||||
penalty_out_of_window_points: "",
|
||||
dupe_scope: "BAND",
|
||||
callsign_normalization: "IGNORE_SUFFIX",
|
||||
distance_rounding: "FLOOR",
|
||||
min_distance_km: "",
|
||||
require_locators: true,
|
||||
out_of_window_policy: "INVALID",
|
||||
exchange_type: "SERIAL_WWL",
|
||||
exchange_requires_wwl: true,
|
||||
exchange_requires_serial: true,
|
||||
exchange_requires_report: false,
|
||||
exchange_pattern: "",
|
||||
match_tiebreak_order: "",
|
||||
match_require_locator_match: false,
|
||||
match_require_exchange_match: false,
|
||||
multiplier_scope: "PER_BAND",
|
||||
multiplier_source: "VALID_ONLY",
|
||||
wwl_multiplier_level: "LOCATOR_6",
|
||||
checklog_matching: true,
|
||||
out_of_window_dq_threshold: "",
|
||||
time_diff_dq_threshold_percent: "",
|
||||
time_diff_dq_threshold_sec: "",
|
||||
bad_qso_dq_threshold_percent: "",
|
||||
time_tolerance_sec: "",
|
||||
allow_time_shift_one_hour: true,
|
||||
time_shift_seconds: "3600",
|
||||
time_mismatch_policy: "FLAG_ONLY",
|
||||
allow_time_mismatch_pairing: true,
|
||||
time_mismatch_max_sec: "",
|
||||
require_unique_qso: true,
|
||||
ignore_slash_part: true,
|
||||
ignore_third_part: true,
|
||||
rst_ignore_third_char: true,
|
||||
callsign_suffix_max_len: "4",
|
||||
callsign_levenshtein_max: "2",
|
||||
letters_in_rst: true,
|
||||
discard_qso_rec_diff_call: true,
|
||||
discard_qso_sent_diff_call: false,
|
||||
discard_qso_rec_diff_rst: true,
|
||||
discard_qso_sent_diff_rst: false,
|
||||
discard_qso_rec_diff_serial: true,
|
||||
discard_qso_sent_diff_serial: false,
|
||||
discard_qso_rec_diff_wwl: true,
|
||||
discard_qso_sent_diff_wwl: false,
|
||||
discard_qso_rec_diff_code: true,
|
||||
discard_qso_sent_diff_code: false,
|
||||
dup_resolution_strategy: "paired_first, ok_first, earlier_time, lower_id",
|
||||
operating_window_mode: "NONE",
|
||||
operating_window_hours: "",
|
||||
sixhr_ranking_mode: "IARU",
|
||||
};
|
||||
|
||||
export const numberValue = (value?: number | null) =>
|
||||
value === null || value === undefined ? "" : String(value);
|
||||
|
||||
export const toNumberOrNull = (value: string) => {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export const toIntOrNull = (value: string) => {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export const isBlank = (value: string) => !value.trim();
|
||||
export const isNumberLike = (value: string) =>
|
||||
value.trim() !== "" && Number.isFinite(Number(value));
|
||||
export const isIntegerLike = (value: string) =>
|
||||
value.trim() !== "" && Number.isInteger(Number(value));
|
||||
137
resources/js/components/admin/users/AdminUserForm.tsx
Normal file
137
resources/js/components/admin/users/AdminUserForm.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Checkbox, Input } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type UserItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
type FormMode = "create" | "edit";
|
||||
|
||||
type Props = {
|
||||
mode: FormMode;
|
||||
editing: UserItem | null;
|
||||
submitting: boolean;
|
||||
serverError: string | null;
|
||||
onSubmit: (payload: {
|
||||
name: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function AdminUserForm({
|
||||
mode,
|
||||
editing,
|
||||
submitting,
|
||||
serverError,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("common");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && editing) {
|
||||
setName(editing.name);
|
||||
setEmail(editing.email);
|
||||
setPassword("");
|
||||
setIsAdmin(editing.is_admin);
|
||||
setIsActive(editing.is_active);
|
||||
} else {
|
||||
setName("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setIsAdmin(false);
|
||||
setIsActive(true);
|
||||
}
|
||||
}, [mode, editing]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
is_admin: isAdmin,
|
||||
is_active: isActive,
|
||||
} as {
|
||||
name: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
if (password.trim()) {
|
||||
payload.password = password.trim();
|
||||
}
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded border border-divider p-4 space-y-3">
|
||||
<div className="text-sm font-semibold">
|
||||
{mode === "edit"
|
||||
? t("admin_users_edit_title") ?? "Upravit uživatele"
|
||||
: t("admin_users_create_title") ?? "Nový uživatel"}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Input
|
||||
label={t("admin_users_name") ?? "Jméno"}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<Input
|
||||
label={t("admin_users_email") ?? "Email"}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<Input
|
||||
label={t("admin_users_password") ?? "Heslo"}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
size="sm"
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? t("admin_users_password_hint") ?? "Nech prázdné pro beze změny"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-6">
|
||||
<Checkbox isSelected={isAdmin} onValueChange={setIsAdmin}>
|
||||
{t("admin_users_is_admin") ?? "Admin"}
|
||||
</Checkbox>
|
||||
<Checkbox isSelected={isActive} onValueChange={setIsActive}>
|
||||
{t("admin_users_is_active") ?? "Aktivní"}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverError && <div className="text-red-600 text-sm">{serverError}</div>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button color="primary" isDisabled={submitting} onPress={handleSubmit}>
|
||||
{submitting ? t("admin_users_saving") ?? "Ukládám…" : t("admin_users_save") ?? "Uložit"}
|
||||
</Button>
|
||||
<Button variant="light" onPress={onCancel}>
|
||||
{t("admin_form_close") ?? "Zavřít formulář"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
resources/js/components/admin/users/AdminUsersTable.tsx
Normal file
67
resources/js/components/admin/users/AdminUsersTable.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { Button, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type UserItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
items: UserItem[];
|
||||
loading: boolean;
|
||||
onEdit: (item: UserItem) => void;
|
||||
onDeactivate: (item: UserItem) => void;
|
||||
};
|
||||
|
||||
export default function AdminUsersTable({ items, loading, onEdit, onDeactivate }: Props) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Table aria-label="Users table">
|
||||
<TableHeader>
|
||||
<TableColumn>{t("admin_users_name") ?? "Jméno"}</TableColumn>
|
||||
<TableColumn>{t("admin_users_email") ?? "Email"}</TableColumn>
|
||||
<TableColumn>{t("admin_users_is_admin") ?? "Admin"}</TableColumn>
|
||||
<TableColumn>{t("admin_users_is_active") ?? "Aktivní"}</TableColumn>
|
||||
<TableColumn>{t("admin_users_actions") ?? "Akce"}</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
items={items}
|
||||
emptyContent={
|
||||
loading
|
||||
? t("admin_users_loading") ?? "Načítám..."
|
||||
: t("admin_users_empty") ?? "Žádní uživatelé."
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.email}</TableCell>
|
||||
<TableCell>{item.is_admin ? "ANO" : "NE"}</TableCell>
|
||||
<TableCell>{item.is_active ? "ANO" : "NE"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="bordered" onPress={() => onEdit(item)}>
|
||||
{t("admin_users_edit") ?? "Upravit"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="light"
|
||||
onPress={() => onDeactivate(item)}
|
||||
isDisabled={!item.is_active}
|
||||
>
|
||||
{t("admin_users_deactivate") ?? "Deaktivovat"}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user