Add inline administration for app and user information management
Introduces an inline administration panel accessible via a sidebar button, allowing users to edit application metadata (repo URL, deployment date, user name, role, initials) and manage data through JSON export/import functionality. The API server now includes routes for exporting all application data and importing it, overwriting existing data after confirmation. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 55837015-10e9-4be9-b857-7f5e6be73772 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 018e63b4-5f39-4959-a629-0ca47a1538c3 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1cc377db-7ea0-49f2-97ce-c3e87e0228cc/55837015-10e9-4be9-b857-7f5e6be73772/KWU6fDX Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
f1b1285b70
commit
da5775ff17
120
artifacts/api-server/src/routes/admin.ts
Normal file
120
artifacts/api-server/src/routes/admin.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { Router, type IRouter } from "express";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
projectsTable,
|
||||||
|
timesheetsTable,
|
||||||
|
timesheetLinesTable,
|
||||||
|
timeEntriesTable,
|
||||||
|
} from "@workspace/db";
|
||||||
|
|
||||||
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
type ImportPayload = {
|
||||||
|
projects: any[];
|
||||||
|
timesheets: any[];
|
||||||
|
timesheetLines: any[];
|
||||||
|
timeEntries: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function readImportPayload(body: unknown): ImportPayload | null {
|
||||||
|
if (!body || typeof body !== "object") return null;
|
||||||
|
const data = body as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
projects: Array.isArray(data.projects) ? data.projects : [],
|
||||||
|
timesheets: Array.isArray(data.timesheets) ? data.timesheets : [],
|
||||||
|
timesheetLines: Array.isArray(data.timesheetLines) ? data.timesheetLines : [],
|
||||||
|
timeEntries: Array.isArray(data.timeEntries) ? data.timeEntries : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asDate(value: unknown) {
|
||||||
|
return value ? new Date(String(value)) : new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/admin/export", async (_req, res): Promise<void> => {
|
||||||
|
const [projects, timesheets, timesheetLines, timeEntries] = await Promise.all([
|
||||||
|
db.select().from(projectsTable),
|
||||||
|
db.select().from(timesheetsTable),
|
||||||
|
db.select().from(timesheetLinesTable),
|
||||||
|
db.select().from(timeEntriesTable),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
version: 1,
|
||||||
|
projects,
|
||||||
|
timesheets,
|
||||||
|
timesheetLines,
|
||||||
|
timeEntries,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/admin/import", async (req, res): Promise<void> => {
|
||||||
|
const data = readImportPayload(req.body);
|
||||||
|
if (!data) {
|
||||||
|
res.status(400).json({ error: "Fichier d'import invalide" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.delete(timeEntriesTable);
|
||||||
|
await tx.delete(timesheetLinesTable);
|
||||||
|
await tx.delete(timesheetsTable);
|
||||||
|
await tx.delete(projectsTable);
|
||||||
|
|
||||||
|
if (data.projects.length > 0) {
|
||||||
|
await tx.insert(projectsTable).values(data.projects.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
code: project.code,
|
||||||
|
name: project.name,
|
||||||
|
parentProjectId: project.parentProjectId ?? null,
|
||||||
|
client: project.client ?? null,
|
||||||
|
category: project.category ?? null,
|
||||||
|
isActive: project.isActive ?? true,
|
||||||
|
createdAt: asDate(project.createdAt),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.timesheets.length > 0) {
|
||||||
|
await tx.insert(timesheetsTable).values(data.timesheets.map((timesheet) => ({
|
||||||
|
id: timesheet.id,
|
||||||
|
year: timesheet.year,
|
||||||
|
month: timesheet.month,
|
||||||
|
status: timesheet.status,
|
||||||
|
collaborator: timesheet.collaborator,
|
||||||
|
totalHours: timesheet.totalHours ?? 0,
|
||||||
|
createdAt: asDate(timesheet.createdAt),
|
||||||
|
updatedAt: asDate(timesheet.updatedAt),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.timesheetLines.length > 0) {
|
||||||
|
await tx.insert(timesheetLinesTable).values(data.timesheetLines.map((line) => ({
|
||||||
|
id: line.id,
|
||||||
|
timesheetId: line.timesheetId,
|
||||||
|
projectId: line.projectId,
|
||||||
|
totalHours: line.totalHours ?? 0,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.timeEntries.length > 0) {
|
||||||
|
await tx.insert(timeEntriesTable).values(data.timeEntries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
timesheetLineId: entry.timesheetLineId,
|
||||||
|
date: entry.date.slice(0, 10),
|
||||||
|
hours: entry.hours,
|
||||||
|
description: entry.description ?? null,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.execute(sql`SELECT setval(pg_get_serial_sequence('projects', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM projects), 1), 1), (SELECT COUNT(*) > 0 FROM projects))`);
|
||||||
|
await tx.execute(sql`SELECT setval(pg_get_serial_sequence('timesheets', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM timesheets), 1), 1), (SELECT COUNT(*) > 0 FROM timesheets))`);
|
||||||
|
await tx.execute(sql`SELECT setval(pg_get_serial_sequence('timesheet_lines', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM timesheet_lines), 1), 1), (SELECT COUNT(*) > 0 FROM timesheet_lines))`);
|
||||||
|
await tx.execute(sql`SELECT setval(pg_get_serial_sequence('time_entries', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM time_entries), 1), 1), (SELECT COUNT(*) > 0 FROM time_entries))`);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -4,6 +4,7 @@ import projectsRouter from "./projects";
|
|||||||
import timesheetsRouter from "./timesheets";
|
import timesheetsRouter from "./timesheets";
|
||||||
import dashboardRouter from "./dashboard";
|
import dashboardRouter from "./dashboard";
|
||||||
import quickEntryRouter from "./quickEntry";
|
import quickEntryRouter from "./quickEntry";
|
||||||
|
import adminRouter from "./admin";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@ -12,5 +13,6 @@ router.use(projectsRouter);
|
|||||||
router.use(timesheetsRouter);
|
router.use(timesheetsRouter);
|
||||||
router.use(dashboardRouter);
|
router.use(dashboardRouter);
|
||||||
router.use(quickEntryRouter);
|
router.use(quickEntryRouter);
|
||||||
|
router.use(adminRouter);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
import { AppSidebar } from "./sidebar";
|
import { AppSidebar } from "./sidebar";
|
||||||
import { Github } from "lucide-react";
|
import { Github } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
const REPO_URL = "https://github.com/sylvainp/cra-app";
|
import { APP_INFO_EVENT, getAppInfo, type AppInfo } from "@/lib/app-info";
|
||||||
const DEPLOY_DATE = "14 avril 2025";
|
|
||||||
|
|
||||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [info, setInfo] = useState<AppInfo>(() => getAppInfo());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = () => setInfo(getAppInfo());
|
||||||
|
window.addEventListener(APP_INFO_EVENT, handleUpdate);
|
||||||
|
return () => window.removeEventListener(APP_INFO_EVENT, handleUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full bg-background overflow-hidden">
|
<div className="flex h-screen w-full bg-background overflow-hidden">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
@ -14,7 +21,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
<footer className="shrink-0 border-t px-8 py-3 flex items-center justify-between text-xs text-muted-foreground">
|
<footer className="shrink-0 border-t px-8 py-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<a
|
<a
|
||||||
href={REPO_URL}
|
href={info.repoUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||||
@ -22,7 +29,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
<Github className="h-3.5 w-3.5" />
|
<Github className="h-3.5 w-3.5" />
|
||||||
Repo GitHub
|
Repo GitHub
|
||||||
</a>
|
</a>
|
||||||
<span>Déployé le {DEPLOY_DATE}</span>
|
<span>Déployé le {info.deployDate}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { LayoutDashboard, Clock, FolderKanban, Zap } from "lucide-react";
|
import { Download, FolderKanban, LayoutDashboard, Settings, Upload, Clock } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { QuickEntryButton } from "@/components/quick-entry";
|
import { QuickEntryButton } from "@/components/quick-entry";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { getAppInfo, saveAppInfo, type AppInfo } from "@/lib/app-info";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
@ -23,6 +30,62 @@ const navItems = [
|
|||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
|
const [isAdminOpen, setIsAdminOpen] = useState(false);
|
||||||
|
const [info, setInfo] = useState<AppInfo>(() => getAppInfo());
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleOpenAdmin = () => {
|
||||||
|
setInfo(getAppInfo());
|
||||||
|
setIsAdminOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveInfo = () => {
|
||||||
|
saveAppInfo(info);
|
||||||
|
toast({ title: "Informations mises à jour" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
const response = await fetch("/api/admin/export");
|
||||||
|
if (!response.ok) {
|
||||||
|
toast({ title: "Export impossible", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `cra-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (!confirm("Importer ce fichier remplacera toutes les données actuelles. Continuer ?")) return;
|
||||||
|
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await file.text());
|
||||||
|
const response = await fetch("/api/admin/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Import impossible");
|
||||||
|
|
||||||
|
toast({ title: "Import terminé" });
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Fichier invalide ou import impossible", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-64 flex-col bg-sidebar border-r border-sidebar-border text-sidebar-foreground">
|
<div className="flex h-screen w-64 flex-col bg-sidebar border-r border-sidebar-border text-sidebar-foreground">
|
||||||
@ -56,19 +119,103 @@ export function AppSidebar() {
|
|||||||
<div className="pt-3">
|
<div className="pt-3">
|
||||||
<QuickEntryButton />
|
<QuickEntryButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenAdmin}
|
||||||
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors cursor-pointer text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Admin
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-sidebar-border">
|
<div className="p-4 border-t border-sidebar-border">
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-3 px-3 py-2">
|
||||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||||
JD
|
{info.userInitials}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium">Jean Dupont</span>
|
<span className="text-sm font-medium">{info.userName}</span>
|
||||||
<span className="text-xs text-muted-foreground">Consultant</span>
|
<span className="text-xs text-muted-foreground">{info.userRole}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isAdminOpen} onOpenChange={setIsAdminOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Administration</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">Informations affichées</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Nom</Label>
|
||||||
|
<Input value={info.userName} onChange={(e) => setInfo({ ...info, userName: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Rôle</Label>
|
||||||
|
<Input value={info.userRole} onChange={(e) => setInfo({ ...info, userRole: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Initiales</Label>
|
||||||
|
<Input value={info.userInitials} onChange={(e) => setInfo({ ...info, userInitials: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Date de déploiement</Label>
|
||||||
|
<Input value={info.deployDate} onChange={(e) => setInfo({ ...info, deployDate: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label>Lien du repo</Label>
|
||||||
|
<Input value={info.repoUrl} onChange={(e) => setInfo({ ...info, repoUrl: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleSaveInfo}>
|
||||||
|
Enregistrer les informations
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<h3 className="text-sm font-semibold">Données</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExport} className="gap-2">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Exporter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isImporting}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Importer
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleImport(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
L'import remplace les projets, CRA, lignes et saisies existants.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsAdminOpen(false)}>
|
||||||
|
Fermer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
36
artifacts/cra-app/src/lib/app-info.ts
Normal file
36
artifacts/cra-app/src/lib/app-info.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
export type AppInfo = {
|
||||||
|
repoUrl: string;
|
||||||
|
deployDate: string;
|
||||||
|
userName: string;
|
||||||
|
userRole: string;
|
||||||
|
userInitials: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APP_INFO_EVENT = "cra-app-info-updated";
|
||||||
|
|
||||||
|
export const DEFAULT_APP_INFO: AppInfo = {
|
||||||
|
repoUrl: "https://github.com/sylvainp/cra-app",
|
||||||
|
deployDate: "14 avril 2025",
|
||||||
|
userName: "Jean Dupont",
|
||||||
|
userRole: "Consultant",
|
||||||
|
userInitials: "JD",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "cra-app-info";
|
||||||
|
|
||||||
|
export function getAppInfo(): AppInfo {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_APP_INFO;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return DEFAULT_APP_INFO;
|
||||||
|
return { ...DEFAULT_APP_INFO, ...JSON.parse(raw) };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_APP_INFO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAppInfo(info: AppInfo) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(info));
|
||||||
|
window.dispatchEvent(new CustomEvent(APP_INFO_EVENT, { detail: info }));
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ A French timesheet management application (CRA - Compte Rendu d'Activité) built
|
|||||||
- **CRA Grid**: Interactive calendar grid where rows = projects, columns = days of month. Click cells to open popover with hour options [0, 0.5, 1, 2, 3, 4, 5, 6, 7, 8]. Includes optional description per cell (amber dot indicator). Auto-save with debounce. Weekend distinction, row/column totals
|
- **CRA Grid**: Interactive calendar grid where rows = projects, columns = days of month. Click cells to open popover with hour options [0, 0.5, 1, 2, 3, 4, 5, 6, 7, 8]. Includes optional description per cell (amber dot indicator). Auto-save with debounce. Weekend distinction, row/column totals
|
||||||
- **Project Management**: CRUD for projects with code, name, client, category
|
- **Project Management**: CRUD for projects with code, name, client, category
|
||||||
- **Timesheet Workflow**: Draft → Submitted → Validated status flow
|
- **Timesheet Workflow**: Draft → Submitted → Validated status flow
|
||||||
|
- **Inline Administration**: Sidebar Admin dialog to edit displayed app/user metadata and export/import project, CRA, line, and time-entry data as JSON
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user