tambah fitur upload data pendukung
This commit is contained in:
@@ -60,6 +60,9 @@ export const recipientRouter = createRouter({
|
|||||||
placeOfWorshipId: z.number().min(1),
|
placeOfWorshipId: z.number().min(1),
|
||||||
registeredBy: z.number().optional(),
|
registeredBy: z.number().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
ktpDocument: z.string().min(1),
|
||||||
|
kkDocument: z.string().min(1),
|
||||||
|
sktmDocument: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -73,6 +76,9 @@ export const recipientRouter = createRouter({
|
|||||||
phone: input.phone ?? null,
|
phone: input.phone ?? null,
|
||||||
registeredBy: input.registeredBy ?? null,
|
registeredBy: input.registeredBy ?? null,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
|
ktpDocument: input.ktpDocument,
|
||||||
|
kkDocument: input.kkDocument,
|
||||||
|
sktmDocument: input.sktmDocument ?? null,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,6 +111,9 @@ export const recipientRouter = createRouter({
|
|||||||
incomePerMonth: z.number().min(0).optional(),
|
incomePerMonth: z.number().min(0).optional(),
|
||||||
placeOfWorshipId: z.number().optional(),
|
placeOfWorshipId: z.number().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
ktpDocument: z.string().optional(),
|
||||||
|
kkDocument: z.string().optional(),
|
||||||
|
sktmDocument: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export const recipients = mysqlTable(
|
|||||||
verifiedBy: bigint("verified_by", { mode: "number", unsigned: true }),
|
verifiedBy: bigint("verified_by", { mode: "number", unsigned: true }),
|
||||||
verifiedAt: timestamp("verified_at"),
|
verifiedAt: timestamp("verified_at"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
ktpDocument: text("ktp_document"),
|
||||||
|
kkDocument: text("kk_document"),
|
||||||
|
sktmDocument: text("sktm_document"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull().$onUpdate(() => new Date()),
|
updatedAt: timestamp("updated_at").defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useId, useMemo, type ChangeEvent } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { FileUp, FileText, Image as ImageIcon, X } from "lucide-react";
|
||||||
|
|
||||||
|
type DocumentUploadFieldProps = {
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
onChange?: (value: string | null) => void;
|
||||||
|
required?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
accept?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result ?? ""));
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error("Gagal membaca file"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewKind(value?: string | null) {
|
||||||
|
if (!value) return "none";
|
||||||
|
if (value.startsWith("data:application/pdf")) return "pdf";
|
||||||
|
if (value.startsWith("data:image/")) return "image";
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentUploadField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required = false,
|
||||||
|
helperText,
|
||||||
|
accept = "image/*,application/pdf",
|
||||||
|
className = "",
|
||||||
|
}: DocumentUploadFieldProps) {
|
||||||
|
const inputId = useId();
|
||||||
|
const previewKind = useMemo(() => getPreviewKind(value), [value]);
|
||||||
|
|
||||||
|
const handleSelectFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file || !onChange) return;
|
||||||
|
const dataUrl = await readFileAsDataUrl(file);
|
||||||
|
onChange(dataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canEdit = Boolean(onChange);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-2 ${className}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label htmlFor={inputId} className="text-xs font-medium">
|
||||||
|
{label} {required ? <span className="text-red-500">*</span> : null}
|
||||||
|
</Label>
|
||||||
|
{canEdit && value ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-[11px]"
|
||||||
|
onClick={() => onChange?.(null)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{helperText ? <p className="text-[11px] text-muted-foreground">{helperText}</p> : null}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleSelectFile}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className={`block cursor-pointer rounded-xl border border-dashed p-4 transition-colors ${
|
||||||
|
value ? "bg-muted/20 hover:bg-muted/30" : "bg-background hover:bg-muted/20"
|
||||||
|
} ${!canEdit ? "cursor-default" : ""}`}
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{previewKind === "image" ? (
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>Dokumen siap dipreview</span>
|
||||||
|
</div>
|
||||||
|
{previewKind === "image" ? (
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt={label}
|
||||||
|
className="h-40 w-full rounded-lg border object-contain bg-white"
|
||||||
|
/>
|
||||||
|
) : previewKind === "pdf" ? (
|
||||||
|
<iframe
|
||||||
|
src={value}
|
||||||
|
title={label}
|
||||||
|
className="h-40 w-full rounded-lg border bg-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-40 items-center justify-center rounded-lg border bg-white text-xs text-muted-foreground">
|
||||||
|
File terunggah
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canEdit ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<FileUp className="h-3.5 w-3.5" />
|
||||||
|
Klik untuk mengganti file
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-32 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||||
|
<FileUp className="h-6 w-6" />
|
||||||
|
<p className="text-sm font-medium">Klik untuk upload atau seret file ke sini</p>
|
||||||
|
<p className="text-[11px]">{accept.toUpperCase()} {required ? "- Wajib" : "- Opsional"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { trpc } from "@/providers/trpc";
|
import { trpc } from "@/providers/trpc";
|
||||||
import { LocationPicker } from "@/components/LocationPicker";
|
import { LocationPicker } from "@/components/LocationPicker";
|
||||||
|
import { DocumentUploadField } from "@/components/DocumentUploadField";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -96,6 +97,9 @@ export default function RecipientsPage() {
|
|||||||
notes: "",
|
notes: "",
|
||||||
latitude: "",
|
latitude: "",
|
||||||
longitude: "",
|
longitude: "",
|
||||||
|
ktpDocument: "",
|
||||||
|
kkDocument: "",
|
||||||
|
sktmDocument: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -162,6 +166,9 @@ export default function RecipientsPage() {
|
|||||||
notes: "",
|
notes: "",
|
||||||
latitude: "",
|
latitude: "",
|
||||||
longitude: "",
|
longitude: "",
|
||||||
|
ktpDocument: "",
|
||||||
|
kkDocument: "",
|
||||||
|
sktmDocument: "",
|
||||||
});
|
});
|
||||||
setSelectedRecipient(null);
|
setSelectedRecipient(null);
|
||||||
};
|
};
|
||||||
@@ -170,6 +177,7 @@ export default function RecipientsPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId;
|
const resolvedPlaceOfWorshipId = nearestPlaceForForm?.id ?? formData.placeOfWorshipId;
|
||||||
if (!formData.nik || !formData.name || !resolvedPlaceOfWorshipId) return;
|
if (!formData.nik || !formData.name || !resolvedPlaceOfWorshipId) return;
|
||||||
|
if (!formData.ktpDocument || !formData.kkDocument) return;
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
nik: formData.nik,
|
nik: formData.nik,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
@@ -183,6 +191,9 @@ export default function RecipientsPage() {
|
|||||||
notes: formData.notes || undefined,
|
notes: formData.notes || undefined,
|
||||||
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
|
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
|
||||||
longitude: formData.longitude ? parseFloat(formData.longitude) : undefined,
|
longitude: formData.longitude ? parseFloat(formData.longitude) : undefined,
|
||||||
|
ktpDocument: formData.ktpDocument,
|
||||||
|
kkDocument: formData.kkDocument,
|
||||||
|
sktmDocument: formData.sktmDocument || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -200,6 +211,9 @@ export default function RecipientsPage() {
|
|||||||
notes: recipient.notes ?? "",
|
notes: recipient.notes ?? "",
|
||||||
latitude: recipient.latitude?.toString() ?? "",
|
latitude: recipient.latitude?.toString() ?? "",
|
||||||
longitude: recipient.longitude?.toString() ?? "",
|
longitude: recipient.longitude?.toString() ?? "",
|
||||||
|
ktpDocument: recipient.ktpDocument ?? "",
|
||||||
|
kkDocument: recipient.kkDocument ?? "",
|
||||||
|
sktmDocument: recipient.sktmDocument ?? "",
|
||||||
});
|
});
|
||||||
setSelectedRecipient(recipient.id);
|
setSelectedRecipient(recipient.id);
|
||||||
setIsEditOpen(true);
|
setIsEditOpen(true);
|
||||||
@@ -223,6 +237,9 @@ export default function RecipientsPage() {
|
|||||||
notes: formData.notes || undefined,
|
notes: formData.notes || undefined,
|
||||||
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
|
latitude: formData.latitude ? parseFloat(formData.latitude) : undefined,
|
||||||
longitude: formData.longitude ? parseFloat(formData.longitude) : undefined,
|
longitude: formData.longitude ? parseFloat(formData.longitude) : undefined,
|
||||||
|
ktpDocument: formData.ktpDocument || undefined,
|
||||||
|
kkDocument: formData.kkDocument || undefined,
|
||||||
|
sktmDocument: formData.sktmDocument || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -339,6 +356,30 @@ export default function RecipientsPage() {
|
|||||||
label="Pilih titik lokasi dari peta"
|
label="Pilih titik lokasi dari peta"
|
||||||
hint="Klik atau geser pin. Alamat akan terisi otomatis dari koordinat yang dipilih."
|
hint="Klik atau geser pin. Alamat akan terisi otomatis dari koordinat yang dipilih."
|
||||||
/>
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<DocumentUploadField
|
||||||
|
label="Foto KTP (Kartu Tanda Penduduk)"
|
||||||
|
value={formData.ktpDocument}
|
||||||
|
onChange={(value) => updateFormData({ ktpDocument: value ?? "" })}
|
||||||
|
required
|
||||||
|
helperText="JPG, PNG, PDF - Maks 5MB"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<DocumentUploadField
|
||||||
|
label="Foto Kartu Keluarga (KK)"
|
||||||
|
value={formData.kkDocument}
|
||||||
|
onChange={(value) => updateFormData({ kkDocument: value ?? "" })}
|
||||||
|
required
|
||||||
|
helperText="JPG, PNG, PDF"
|
||||||
|
/>
|
||||||
|
<DocumentUploadField
|
||||||
|
label="Surat Keterangan Tidak Mampu (SKTM)"
|
||||||
|
value={formData.sktmDocument}
|
||||||
|
onChange={(value) => updateFormData({ sktmDocument: value ?? "" })}
|
||||||
|
helperText="JPG, PNG, PDF (Opsional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Telepon</Label>
|
<Label className="text-xs">Telepon</Label>
|
||||||
@@ -371,7 +412,11 @@ export default function RecipientsPage() {
|
|||||||
<Label className="text-xs">Catatan</Label>
|
<Label className="text-xs">Catatan</Label>
|
||||||
<Input value={formData.notes} onChange={(e) => updateFormData({ notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" />
|
<Input value={formData.notes} onChange={(e) => updateFormData({ notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full" disabled={createMutation.isPending}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={createMutation.isPending || !formData.ktpDocument || !formData.kkDocument}
|
||||||
|
>
|
||||||
{createMutation.isPending ? "Menyimpan..." : "Simpan"}
|
{createMutation.isPending ? "Menyimpan..." : "Simpan"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -539,6 +584,30 @@ export default function RecipientsPage() {
|
|||||||
label="Pilih titik lokasi dari peta"
|
label="Pilih titik lokasi dari peta"
|
||||||
hint="Klik atau geser pin. Alamat akan terisi otomatis dari koordinat yang dipilih."
|
hint="Klik atau geser pin. Alamat akan terisi otomatis dari koordinat yang dipilih."
|
||||||
/>
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<DocumentUploadField
|
||||||
|
label="Foto KTP (Kartu Tanda Penduduk)"
|
||||||
|
value={formData.ktpDocument}
|
||||||
|
onChange={(value) => updateFormData({ ktpDocument: value ?? "" })}
|
||||||
|
required
|
||||||
|
helperText="JPG, PNG, PDF - Maks 5MB"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<DocumentUploadField
|
||||||
|
label="Foto Kartu Keluarga (KK)"
|
||||||
|
value={formData.kkDocument}
|
||||||
|
onChange={(value) => updateFormData({ kkDocument: value ?? "" })}
|
||||||
|
required
|
||||||
|
helperText="JPG, PNG, PDF"
|
||||||
|
/>
|
||||||
|
<DocumentUploadField
|
||||||
|
label="Surat Keterangan Tidak Mampu (SKTM)"
|
||||||
|
value={formData.sktmDocument}
|
||||||
|
onChange={(value) => updateFormData({ sktmDocument: value ?? "" })}
|
||||||
|
helperText="JPG, PNG, PDF (Opsional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Telepon</Label>
|
<Label className="text-xs">Telepon</Label>
|
||||||
@@ -604,6 +673,16 @@ export default function RecipientsPage() {
|
|||||||
{selectedRec.notes && (
|
{selectedRec.notes && (
|
||||||
<div className="text-xs bg-muted p-2 rounded-lg"><span className="text-muted-foreground">Catatan:</span> {selectedRec.notes}</div>
|
<div className="text-xs bg-muted p-2 rounded-lg"><span className="text-muted-foreground">Catatan:</span> {selectedRec.notes}</div>
|
||||||
)}
|
)}
|
||||||
|
{(selectedRec.ktpDocument || selectedRec.kkDocument || selectedRec.sktmDocument) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium">Dokumen Terunggah</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<DocumentUploadField label="KTP" value={selectedRec.ktpDocument} />
|
||||||
|
<DocumentUploadField label="KK" value={selectedRec.kkDocument} />
|
||||||
|
<DocumentUploadField label="SKTM" value={selectedRec.sktmDocument} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { DocumentUploadField } from "@/components/DocumentUploadField";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -183,6 +184,16 @@ export default function VerificationPage() {
|
|||||||
{r.notes && (
|
{r.notes && (
|
||||||
<p className="text-xs bg-muted p-2 rounded-lg">{r.notes}</p>
|
<p className="text-xs bg-muted p-2 rounded-lg">{r.notes}</p>
|
||||||
)}
|
)}
|
||||||
|
{(r.ktpDocument || r.kkDocument || r.sktmDocument) && (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<p className="text-[11px] font-medium text-muted-foreground">Preview Dokumen</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<DocumentUploadField label="KTP" value={r.ktpDocument} />
|
||||||
|
<DocumentUploadField label="KK" value={r.kkDocument} />
|
||||||
|
<DocumentUploadField label="SKTM" value={r.sktmDocument} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user