Add functionality to manage visit photos and albums for protocols
Introduce new database tables for photo albums and photos, add API endpoints for CRUD operations on albums and photos, and implement frontend components for managing photo albums. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: ad3facca-9626-4e19-95c0-125e6d3bc7e2 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1OXOhLE Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -22,6 +22,10 @@ import {
|
||||
List as ListIcon,
|
||||
Link2,
|
||||
Copy,
|
||||
Camera,
|
||||
Upload,
|
||||
Loader2,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -54,6 +58,7 @@ import {
|
||||
import { AppDock } from "@/components/app-dock";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiJson, ApiError } from "@/lib/api-json";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const API = `${import.meta.env.BASE_URL}api`;
|
||||
@@ -185,6 +190,7 @@ type TabKey =
|
||||
| "external"
|
||||
| "gifts"
|
||||
| "issues"
|
||||
| "photos"
|
||||
| "reports"
|
||||
| "audit";
|
||||
|
||||
@@ -196,6 +202,7 @@ const TAB_SLUGS: Record<TabKey, string> = {
|
||||
external: "external-meetings",
|
||||
gifts: "gifts",
|
||||
issues: "gift-issues",
|
||||
photos: "photos",
|
||||
reports: "reports",
|
||||
audit: "audit",
|
||||
};
|
||||
@@ -501,6 +508,7 @@ export default function ProtocolPage() {
|
||||
{ key: "external", label: t("protocol.tabs.external"), icon: Handshake },
|
||||
{ key: "gifts", label: t("protocol.tabs.gifts"), icon: Gift },
|
||||
{ key: "issues", label: t("protocol.tabs.issues"), icon: PackageCheck },
|
||||
{ key: "photos", label: t("protocol.tabs.photos"), icon: Camera },
|
||||
{ key: "reports", label: t("protocol.tabs.reports"), icon: BarChart3 },
|
||||
...(c?.canViewAudit
|
||||
? [{ key: "audit" as TabKey, label: t("protocol.tabs.audit"), icon: ScrollText }]
|
||||
@@ -1077,6 +1085,15 @@ export default function ProtocolPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "photos" && (
|
||||
<PhotosView
|
||||
canMutate={!!c?.canMutate}
|
||||
isAr={isAr}
|
||||
externalMeetings={external.data ?? []}
|
||||
onApiError={onApiError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "reports" && (
|
||||
<ReportsView data={reports.data} nameOf={nameOf} t={t} />
|
||||
)}
|
||||
@@ -1939,3 +1956,428 @@ function IssueDialog({
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visit photos (تصوير) — album grid + album detail with upload gallery.
|
||||
// ---------------------------------------------------------------------------
|
||||
type Album = {
|
||||
id: number;
|
||||
name: string;
|
||||
externalMeetingId: number | null;
|
||||
meetingTitle: string | null;
|
||||
createdBy: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
photoCount: number;
|
||||
coverPath: string | null;
|
||||
};
|
||||
type Photo = {
|
||||
id: number;
|
||||
albumId: number;
|
||||
objectPath: string;
|
||||
sortOrder: number;
|
||||
uploadedBy: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// Build the authorized image URL from a stored `/objects/<id>` path.
|
||||
const photoSrc = (objectPath: string) => `${API}/storage${objectPath}`;
|
||||
|
||||
function PhotosView({
|
||||
canMutate,
|
||||
isAr,
|
||||
externalMeetings,
|
||||
onApiError,
|
||||
}: {
|
||||
canMutate: boolean;
|
||||
isAr: boolean;
|
||||
externalMeetings: ExternalMeeting[];
|
||||
onApiError: (e: unknown) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [openAlbumId, setOpenAlbumId] = useState<number | null>(null);
|
||||
const [albumDialog, setAlbumDialog] = useState<{
|
||||
open: boolean;
|
||||
edit?: Album;
|
||||
}>({ open: false });
|
||||
const [deleteAlbum, setDeleteAlbum] = useState<Album | null>(null);
|
||||
const [deletePhoto, setDeletePhoto] = useState<Photo | null>(null);
|
||||
const BackIcon = isAr ? ArrowRight : ArrowLeft;
|
||||
|
||||
const albums = useQuery({
|
||||
queryKey: ["protocol", "photo-albums"],
|
||||
queryFn: () => apiJson<Album[]>(`${API}/protocol/photo-albums`),
|
||||
});
|
||||
const photos = useQuery({
|
||||
queryKey: ["protocol", "photos", openAlbumId],
|
||||
queryFn: () =>
|
||||
apiJson<Photo[]>(`${API}/protocol/photo-albums/${openAlbumId}/photos`),
|
||||
enabled: openAlbumId !== null,
|
||||
});
|
||||
|
||||
const openAlbum =
|
||||
openAlbumId !== null
|
||||
? (albums.data ?? []).find((a) => a.id === openAlbumId)
|
||||
: undefined;
|
||||
|
||||
const { uploadFile, isUploading, progress } = useUpload({
|
||||
basePath: `${API}/storage`,
|
||||
onError: onApiError,
|
||||
});
|
||||
|
||||
const finalize = useMutation({
|
||||
mutationFn: (objectPath: string) =>
|
||||
apiJson(`${API}/protocol/photo-albums/${openAlbumId}/photos`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ objectPath }),
|
||||
}),
|
||||
onError: onApiError,
|
||||
});
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || openAlbumId === null) return;
|
||||
for (const file of Array.from(files)) {
|
||||
const resp: UploadResponse | null = await uploadFile(file);
|
||||
if (resp) await finalize.mutateAsync(resp.objectPath);
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] });
|
||||
await qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
|
||||
};
|
||||
|
||||
const delAlbumMut = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiJson(`${API}/protocol/photo-albums/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
setDeleteAlbum(null);
|
||||
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
|
||||
},
|
||||
onError: onApiError,
|
||||
});
|
||||
const delPhotoMut = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiJson(`${API}/protocol/photos/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
setDeletePhoto(null);
|
||||
qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] });
|
||||
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
|
||||
},
|
||||
onError: onApiError,
|
||||
});
|
||||
|
||||
// --- Album detail view -----------------------------------------------------
|
||||
if (openAlbum) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setOpenAlbumId(null)}
|
||||
>
|
||||
<BackIcon size={16} />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-slate-700 truncate">
|
||||
{openAlbum.name}
|
||||
</h2>
|
||||
{openAlbum.meetingTitle && (
|
||||
<div className="text-xs text-slate-400 truncate">
|
||||
<Link2 size={11} className="inline me-1" />
|
||||
{openAlbum.meetingTitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canMutate && (
|
||||
<label className="cursor-pointer shrink-0">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
disabled={isUploading}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void handleFiles(e.target.files);
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:opacity-90 transition">
|
||||
{isUploading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={16} />
|
||||
)}
|
||||
{isUploading
|
||||
? `${t("protocol.photos.uploading")} ${progress}%`
|
||||
: t("protocol.photos.addPhotos")}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{photos.data && photos.data.length === 0 && (
|
||||
<EmptyState text={t("protocol.photos.emptyPhotos")} />
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{(photos.data ?? []).map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="group relative aspect-square rounded-xl overflow-hidden border border-slate-200 bg-slate-100"
|
||||
>
|
||||
<a href={photoSrc(p.objectPath)} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={photoSrc(p.objectPath)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
{canMutate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeletePhoto(p)}
|
||||
className="absolute top-1 end-1 p-1.5 rounded-lg bg-black/50 text-white opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{albumDialog.open && (
|
||||
<AlbumDialog
|
||||
edit={albumDialog.edit}
|
||||
externalMeetings={externalMeetings}
|
||||
onClose={() => setAlbumDialog({ open: false })}
|
||||
onSaved={() => {
|
||||
setAlbumDialog({ open: false });
|
||||
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
|
||||
}}
|
||||
onError={onApiError}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog
|
||||
open={deletePhoto !== null}
|
||||
onOpenChange={(o) => !o && setDeletePhoto(null)}
|
||||
>
|
||||
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("protocol.photos.confirmDeletePhoto")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-rose-600 hover:bg-rose-700"
|
||||
onClick={() => deletePhoto && delPhotoMut.mutate(deletePhoto.id)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Album grid view -------------------------------------------------------
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-slate-700">
|
||||
{t("protocol.tabs.photos")}
|
||||
</h2>
|
||||
{canMutate && (
|
||||
<Button size="sm" onClick={() => setAlbumDialog({ open: true })}>
|
||||
<Plus size={16} className="me-1" />
|
||||
{t("protocol.photos.newAlbum")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{albums.data && albums.data.length === 0 && (
|
||||
<EmptyState text={t("protocol.photos.emptyAlbums")} />
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{(albums.data ?? []).map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="bg-white rounded-xl border border-slate-200 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenAlbumId(a.id)}
|
||||
className="block w-full aspect-square bg-slate-100 relative"
|
||||
>
|
||||
{a.coverPath ? (
|
||||
<img
|
||||
src={photoSrc(a.coverPath)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-slate-300">
|
||||
<ImageIcon size={32} />
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-1 end-1 text-[11px] px-1.5 py-0.5 rounded-full bg-black/55 text-white">
|
||||
{a.photoCount}
|
||||
</span>
|
||||
</button>
|
||||
<div className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenAlbumId(a.id)}
|
||||
className="block w-full text-start font-medium text-sm text-slate-800 truncate"
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
{a.meetingTitle && (
|
||||
<div className="text-[11px] text-slate-400 truncate">
|
||||
<Link2 size={10} className="inline me-1" />
|
||||
{a.meetingTitle}
|
||||
</div>
|
||||
)}
|
||||
{canMutate && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setAlbumDialog({ open: true, edit: a })}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setDeleteAlbum(a)}
|
||||
>
|
||||
<Trash2 size={13} className="text-rose-500" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{albumDialog.open && (
|
||||
<AlbumDialog
|
||||
edit={albumDialog.edit}
|
||||
externalMeetings={externalMeetings}
|
||||
onClose={() => setAlbumDialog({ open: false })}
|
||||
onSaved={() => {
|
||||
setAlbumDialog({ open: false });
|
||||
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
|
||||
}}
|
||||
onError={onApiError}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog
|
||||
open={deleteAlbum !== null}
|
||||
onOpenChange={(o) => !o && setDeleteAlbum(null)}
|
||||
>
|
||||
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("protocol.photos.confirmDeleteAlbum")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-rose-600 hover:bg-rose-700"
|
||||
onClick={() => deleteAlbum && delAlbumMut.mutate(deleteAlbum.id)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AlbumDialog({
|
||||
edit,
|
||||
externalMeetings,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
edit?: Album;
|
||||
externalMeetings: ExternalMeeting[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(edit?.name ?? "");
|
||||
const [meetingId, setMeetingId] = useState<string>(
|
||||
edit?.externalMeetingId != null ? String(edit.externalMeetingId) : "none",
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
externalMeetingId: meetingId === "none" ? null : Number(meetingId),
|
||||
};
|
||||
return edit
|
||||
? apiJson(`${API}/protocol/photo-albums/${edit.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
: apiJson(`${API}/protocol/photo-albums`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
title={edit ? t("protocol.photos.editAlbum") : t("protocol.photos.newAlbum")}
|
||||
onClose={onClose}
|
||||
onSubmit={() => save.mutate()}
|
||||
submitLabel={t("common.save")}
|
||||
saving={save.isPending}
|
||||
>
|
||||
<div>
|
||||
<Label>{t("protocol.photos.albumName")}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("protocol.photos.linkMeeting")}</Label>
|
||||
<Select value={meetingId} onValueChange={setMeetingId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t("protocol.photos.noMeeting")}</SelectItem>
|
||||
{externalMeetings.map((m) => (
|
||||
<SelectItem key={m.id} value={String(m.id)}>
|
||||
{m.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user