feat: refactor file URL resolution and enhance component imports
This commit is contained in:
@@ -2,7 +2,8 @@ import { Bell, Menu } from "lucide-react"
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import humiclogo from "../assets/logo.png";
|
import humiclogo from "../assets/logo.png";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useCurrentUser, resolveFileUrl, getInitials } from "../hooks/useUser";
|
import { useCurrentUser, getInitials } from "../hooks/useUser";
|
||||||
|
import { resolveFileUrl } from "../Pages/utils/resolveFileFromUrl";
|
||||||
import { useNotifications } from "../hooks/useNotification";
|
import { useNotifications } from "../hooks/useNotification";
|
||||||
|
|
||||||
export default function Header({ toggleSidebar }: { toggleSidebar: () => void }) {
|
export default function Header({ toggleSidebar }: { toggleSidebar: () => void }) {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export default function InternifyLogin() {
|
|||||||
type="email"
|
type="email"
|
||||||
placeholder="Email Account"
|
placeholder="Email Account"
|
||||||
value={email}
|
value={email}
|
||||||
|
autoComplete="username"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
|
className="w-full pl-10 pr-4 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
@@ -101,6 +102,7 @@ export default function InternifyLogin() {
|
|||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="Account Password"
|
placeholder="Account Password"
|
||||||
value={password}
|
value={password}
|
||||||
|
autoComplete="current-password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-10 pr-10 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
|
className="w-full pl-10 pr-10 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
interface ProgressBarProps {
|
|
||||||
value: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProgressBar({ value, total }: ProgressBarProps) {
|
|
||||||
const pct = total === 0 ? 0 : Math.round((value / total) * 100);
|
|
||||||
const isComplete = value === total;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
|
|
||||||
{value}/{total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useCurrentUser, useUpdateProfile, resolveFileUrl, getInitials } from "../../hooks/useUser";
|
import { useCurrentUser, useUpdateProfile, getInitials } from "../../hooks/useUser";
|
||||||
import { customToast } from "../utils/showToast";
|
import { customToast } from "../utils/showToast";
|
||||||
import { Pen } from "lucide-react";
|
import { Pen } from "lucide-react";
|
||||||
import UpdateProfileDialog from "./UpdateProfileDialogue";
|
import UpdateProfileDialog from "./UpdateProfileDialogue";
|
||||||
|
import { resolveFileUrl } from "../utils/resolveFileFromUrl";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user, loading: userLoading } = useCurrentUser();
|
const { user, loading: userLoading } = useCurrentUser();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { resolveFileUrl, getInitials } from '../../../hooks/useUser';
|
import { getInitials } from '../../../hooks/useUser';
|
||||||
|
import { resolveFileUrl } from '../../utils/resolveFileFromUrl';
|
||||||
|
|
||||||
export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
|
export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
|
||||||
const [ImageError, setImageError] = useState(false);
|
const [ImageError, setImageError] = useState(false);
|
||||||
|
|||||||
@@ -4,35 +4,35 @@ import { Download, Link, Printer } from "lucide-react";
|
|||||||
import type { ProjectDetail } from "../../../../types/project.types";
|
import type { ProjectDetail } from "../../../../types/project.types";
|
||||||
import type { Certificate } from "../../../../types/certificate.types";
|
import type { Certificate } from "../../../../types/certificate.types";
|
||||||
import { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates";
|
import { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates";
|
||||||
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
|
import { generateCertificate } from "../../../utils/SertificateGenerator";
|
||||||
|
import { resolveFileUrl } from "../../../utils/resolveFileFromUrl";
|
||||||
import { customToast } from "../../../utils/showToast";
|
import { customToast } from "../../../utils/showToast";
|
||||||
|
|
||||||
interface CertificateAvailableProps {
|
interface CertificateAvailableProps {
|
||||||
project: ProjectDetail | null;
|
project: ProjectDetail;
|
||||||
certificate: Certificate;
|
certificate: Certificate;
|
||||||
templateUrl: string;
|
templateUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateIndo(date: string): string {
|
||||||
|
return new Intl.DateTimeFormat("id-ID", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(startDate: string, endDate: string): string {
|
||||||
|
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CertificateAvailable({ project, certificate, templateUrl }: CertificateAvailableProps) {
|
export default function CertificateAvailable({ project, certificate, templateUrl }: CertificateAvailableProps) {
|
||||||
// const [copied, setCopied] = useState(false);
|
|
||||||
const [downloadingImg, setDownloadingImg] = useState(false);
|
const [downloadingImg, setDownloadingImg] = useState(false);
|
||||||
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [loadingPreview, setLoadingPreview] = useState(true);
|
const [loadingPreview, setLoadingPreview] = useState(true);
|
||||||
|
|
||||||
const duration = project
|
const duration = formatDateRange(project?.start_date, project?.end_date)
|
||||||
? (() => {
|
|
||||||
const start = new Date(project.start_date);
|
|
||||||
const end = new Date(project.end_date);
|
|
||||||
const diffMs = end.getTime() - start.getTime();
|
|
||||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
const months = Math.floor(diffDays / 30);
|
|
||||||
const days = diffDays % 30;
|
|
||||||
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
|
|
||||||
if (months > 0) return `${months} Bulan`;
|
|
||||||
return `${diffDays} Hari`;
|
|
||||||
})()
|
|
||||||
: "-";
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,12 +40,13 @@ export default function CertificateAvailable({ project, certificate, templateUrl
|
|||||||
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
|
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
|
||||||
|
|
||||||
generateCertificate(
|
generateCertificate(
|
||||||
resolveImageUrl(templateUrl),
|
resolveFileUrl(templateUrl),
|
||||||
certificate.user.full_name,
|
certificate.user.full_name,
|
||||||
certificate.project.project_name,
|
certificate.project.project_name,
|
||||||
certificate.certificate_no,
|
certificate.certificate_no,
|
||||||
|
duration,
|
||||||
verifyUrl,
|
verifyUrl,
|
||||||
duration)
|
)
|
||||||
.then((blob) => {
|
.then((blob) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
@@ -82,7 +83,6 @@ export default function CertificateAvailable({ project, certificate, templateUrl
|
|||||||
|
|
||||||
const handleShareLinkedIn = () => {
|
const handleShareLinkedIn = () => {
|
||||||
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
|
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
|
||||||
// Gunakan "Add Certification to Profile" LinkedIn — tidak butuh API key/approval
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startTask: "CERTIFICATION_NAME",
|
startTask: "CERTIFICATION_NAME",
|
||||||
name: certificate.project.project_name,
|
name: certificate.project.project_name,
|
||||||
@@ -109,7 +109,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
|
|||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt="Certificate"
|
alt="Certificate"
|
||||||
className="rounded-xl w-[550px] h-[350px] object-contain"
|
className="rounded-xl object-contain border-2"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
|
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getProjectIcon } from '../../../../lib/ProjectIcons';
|
import { getProjectIcon } from '../../../../lib/ProjectIcons';
|
||||||
import type { Project } from '../../../../types/project.types';
|
import type { Project } from '../../../../types/project.types';
|
||||||
import { resolveFileUrl } from '../../../../hooks/useUser';
|
import { resolveFileUrl } from '../../../utils/resolveFileFromUrl';
|
||||||
|
|
||||||
const ProjectCard = (project: Project) => {
|
const ProjectCard = (project: Project) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FileText, FileUp, Link } from "lucide-react";
|
import { FileText, FileUp, Link } from "lucide-react";
|
||||||
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
|
import { resolveFileUrl } from "../../../utils/resolveFileFromUrl";
|
||||||
|
|
||||||
interface SubmitStatusTableProps {
|
interface SubmitStatusTableProps {
|
||||||
type: "file" | "link";
|
type: "file" | "link";
|
||||||
@@ -128,7 +128,7 @@ export default function SubmitStatusTable({
|
|||||||
files?.map((f) => (
|
files?.map((f) => (
|
||||||
<a
|
<a
|
||||||
key={f.id}
|
key={f.id}
|
||||||
href={resolveImageUrl(f.file_path)}
|
href={resolveFileUrl(f.file_path) ?? undefined}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||||
|
|||||||
@@ -159,8 +159,8 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
|||||||
<div className="flex flex-col items-center justify-center h-full space-y-2 text-center py-6">
|
<div className="flex flex-col items-center justify-center h-full space-y-2 text-center py-6">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{submission.files.map((f) => (
|
{submission.files.map((f) => (
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
<div key={f.id} className="flex items-center gap-2 text-[11px]">
|
||||||
<FileText className="w-4 h-4 text-red-700 stroke-[2.5]" />
|
<FileText className="w-4 h-4 text-red-700" />
|
||||||
{f.original_name}
|
{f.original_name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useUploadCertificateTemplate } from "../../../hooks/useCertificates";
|
|||||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||||
import EligibleInternTable from "./components/EligibleInternTable";
|
import EligibleInternTable from "./components/EligibleInternTable";
|
||||||
import { useProjectInternProgress } from "../../../hooks/useInternProgress";
|
import { useProjectInternProgress } from "../../../hooks/useInternProgress";
|
||||||
import { resolveImageUrl } from "../../utils/SertificateGenerator";
|
import { resolveFileUrl } from "../../utils/resolveFileFromUrl";
|
||||||
import { customToast } from "../../utils/showToast";
|
import { customToast } from "../../utils/showToast";
|
||||||
|
|
||||||
export default function CertificateDetail() {
|
export default function CertificateDetail() {
|
||||||
@@ -140,7 +140,7 @@ export default function CertificateDetail() {
|
|||||||
{previewUrl || project.certificate_template ? (
|
{previewUrl || project.certificate_template ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={previewUrl || resolveImageUrl(project.certificate_template)}
|
src={previewUrl || resolveFileUrl(project.certificate_template)}
|
||||||
alt="Preview template"
|
alt="Preview template"
|
||||||
className="w-full h-32 object-contain rounded-lg mb-2"
|
className="w-full h-32 object-contain rounded-lg mb-2"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import ManageInternsModal from './components/AddInternsDialog';
|
|||||||
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
||||||
import { useRemoveMember } from '../../../hooks/useProjects';
|
import { useRemoveMember } from '../../../hooks/useProjects';
|
||||||
import { customToast } from '../../utils/showToast';
|
import { customToast } from '../../utils/showToast';
|
||||||
import { resolveFileUrl, getInitials } from '../../../hooks/useUser';
|
import { getInitials } from '../../../hooks/useUser';
|
||||||
|
import { resolveFileUrl } from '../../utils/resolveFileFromUrl';
|
||||||
|
|
||||||
export default function InternsTab({ project }: { project: ProjectDetail }) {
|
export default function InternsTab({ project }: { project: ProjectDetail }) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -39,7 +40,7 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="border-b border-gray-300 pb-4">
|
<div className="border-b border-gray-300 pb-4">
|
||||||
<div className="flex items-center gap-3 ">
|
<div className="flex items-center gap-3 ">
|
||||||
{adminHasPhoto ? (
|
{adminHasPhoto && !adminImageError ? (
|
||||||
<img
|
<img
|
||||||
src={adminAvatar}
|
src={adminAvatar}
|
||||||
alt={project.admin.full_name}
|
alt={project.admin.full_name}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
|
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
|
import { generateCertificate } from "../../../utils/SertificateGenerator";
|
||||||
|
import { resolveFileUrl } from "../../../utils/resolveFileFromUrl";
|
||||||
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
|
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
|
||||||
import Avatar from "../../Avatar";
|
import Avatar from "../../Avatar";
|
||||||
import ProgressBar from "../../ProgressBar";
|
|
||||||
import { customToast } from "../../../utils/showToast";
|
import { customToast } from "../../../utils/showToast";
|
||||||
import type { Project } from "../../../../types/project.types";
|
import type { Project } from "../../../../types/project.types";
|
||||||
import { previewCertificate } from "../../../utils/Certificates";
|
import { previewCertificate } from "../../../utils/Certificates";
|
||||||
@@ -43,6 +43,25 @@ function formatDateRange(startDate: string, endDate: string): string {
|
|||||||
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
|
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ value, total }: {value: number, total: number}) {
|
||||||
|
const pct = total === 0 ? 0 : Math.round((value / total) * 100);
|
||||||
|
const isComplete = value === total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
|
||||||
|
{value}/{total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: boolean }) {
|
function StatusBadge({ status }: { status: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${status ? "text-green-600" : "text-orange-500"}`}>
|
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${status ? "text-green-600" : "text-orange-500"}`}>
|
||||||
@@ -131,13 +150,15 @@ export default function EligibleInternTable({
|
|||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setGenerateError(null);
|
setGenerateError(null);
|
||||||
|
|
||||||
const ok = await customToast.promise(
|
const res = await customToast.promise(
|
||||||
(async () => {
|
(async () => {
|
||||||
const result = await SubmitCertificates(project.id, Array.from(selected));
|
const result = await SubmitCertificates(project.id, Array.from(selected));
|
||||||
if (!result.data) throw new Error("Tidak ada data sertifikat yang dikembalikan.");
|
if (!result.data) throw new Error("Tidak ada data sertifikat yang dikembalikan.");
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const templateUrl = resolveImageUrl(project.certificate_template!);
|
const templateUrl = resolveFileUrl(project.certificate_template!);
|
||||||
|
if (!templateUrl) throw new Error("Template sertifikat tidak ditemukan.");
|
||||||
|
|
||||||
const duration = formatDateRange(project.start_date, project.end_date);
|
const duration = formatDateRange(project.start_date, project.end_date);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -168,7 +189,7 @@ export default function EligibleInternTable({
|
|||||||
).then(() => true).catch(() => false);
|
).then(() => true).catch(() => false);
|
||||||
|
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
if (ok) navigate(`/mentor/certificates/${project.slug}/result`);
|
if (res) navigate(`/mentor/certificates/${project.slug}/result`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -185,7 +206,7 @@ export default function EligibleInternTable({
|
|||||||
return (
|
return (
|
||||||
<div className="lg:col-span-5 flex flex-col gap-4">
|
<div className="lg:col-span-5 flex flex-col gap-4">
|
||||||
{/* Roster Card */}
|
{/* Roster Card */}
|
||||||
<div className="border border-box-border rounded-2xl overflow-hidden bg-white shadow-sm">
|
<div className="border border-box-border overflow-x-auto rounded-2xl bg-white shadow-sm">
|
||||||
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-100">
|
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-100">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-gray-900">Intern Roster</h3>
|
<h3 className="text-base font-bold text-gray-900">Intern Roster</h3>
|
||||||
@@ -253,7 +274,7 @@ export default function EligibleInternTable({
|
|||||||
{/* Name + role */}
|
{/* Name + role */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{intern.avatar ? (
|
{intern.avatar ? (
|
||||||
<img src={intern.avatar} className="w-10 h-10 rounded-full object-cover" />
|
<img src={intern.avatar} className="w-8 h-8 rounded-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<Avatar initials={initials} />
|
<Avatar initials={initials} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { FileText, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react";
|
import { FileText, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react";
|
||||||
import type { TaskSubmissionData, DisplayStatus } from "../../../../types/task.types";
|
import type { TaskSubmissionData, DisplayStatus } from "../../../../types/task.types";
|
||||||
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
|
import { resolveFileUrl } from "../../../utils/resolveFileFromUrl";
|
||||||
|
|
||||||
// StatusBadge
|
// StatusBadge
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export function StatusTable({ status, submission }: StatusTableProps) {
|
|||||||
{submission.files.map((f) => (
|
{submission.files.map((f) => (
|
||||||
<a
|
<a
|
||||||
key={f.id}
|
key={f.id}
|
||||||
href={resolveImageUrl(f.file_path)}
|
href={resolveFileUrl(f.file_path) ?? undefined}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import type { Certificate } from "../../types/certificate.types";
|
import type { Certificate } from "../../types/certificate.types";
|
||||||
import { generateCertificate, resolveImageUrl } from "./SertificateGenerator";
|
import { generateCertificate } from "./SertificateGenerator";
|
||||||
|
import { resolveFileUrl } from "./resolveFileFromUrl";
|
||||||
|
|
||||||
function formatDateIndo(date: string): string {
|
function formatDateIndo(date: string): string {
|
||||||
return new Intl.DateTimeFormat("id-ID", {
|
return new Intl.DateTimeFormat("id-ID", {
|
||||||
@@ -14,7 +15,7 @@ function formatDateIndo(date: string): string {
|
|||||||
function formatDateRange(startDate: string, endDate: string): string {
|
function formatDateRange(startDate: string, endDate: string): string {
|
||||||
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
|
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
|
||||||
}
|
}
|
||||||
// 1. Preview di tab baru
|
// Preview di tab baru
|
||||||
export const previewCertificate = async (
|
export const previewCertificate = async (
|
||||||
cert: Certificate,
|
cert: Certificate,
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
@@ -22,7 +23,7 @@ export const previewCertificate = async (
|
|||||||
const tab = window.open("", "_blank");
|
const tab = window.open("", "_blank");
|
||||||
|
|
||||||
const blob = await generateCertificate(
|
const blob = await generateCertificate(
|
||||||
resolveImageUrl(templateUrl),
|
resolveFileUrl(templateUrl),
|
||||||
cert.user.full_name,
|
cert.user.full_name,
|
||||||
cert.project.project_name,
|
cert.project.project_name,
|
||||||
cert.certificate_no,
|
cert.certificate_no,
|
||||||
@@ -79,14 +80,14 @@ export const previewCertificate = async (
|
|||||||
tab.document.close();
|
tab.document.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 2. Download PDF ───────────────────────────────────────────────────────────
|
// Download PDF
|
||||||
export const downloadCertificatePdf = async (
|
export const downloadCertificatePdf = async (
|
||||||
cert: Certificate,
|
cert: Certificate,
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
||||||
const blob = await generateCertificate(
|
const blob = await generateCertificate(
|
||||||
resolveImageUrl(templateUrl),
|
resolveFileUrl(templateUrl),
|
||||||
cert.user.full_name,
|
cert.user.full_name,
|
||||||
cert.project.project_name,
|
cert.project.project_name,
|
||||||
cert.certificate_no,
|
cert.certificate_no,
|
||||||
@@ -118,7 +119,7 @@ export const downloadCertificatePdf = async (
|
|||||||
pdf.save(`${fileName}.pdf`);
|
pdf.save(`${fileName}.pdf`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 3. Download PNG atau JPG ──────────────────────────────────────────────────
|
// Download PNG atau JPG
|
||||||
export const downloadCertificateImage = async (
|
export const downloadCertificateImage = async (
|
||||||
cert: Certificate,
|
cert: Certificate,
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
@@ -126,7 +127,7 @@ export const downloadCertificateImage = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
||||||
const blob = await generateCertificate(
|
const blob = await generateCertificate(
|
||||||
resolveImageUrl(templateUrl),
|
resolveFileUrl(templateUrl),
|
||||||
cert.user.full_name,
|
cert.user.full_name,
|
||||||
cert.project.project_name,
|
cert.project.project_name,
|
||||||
cert.certificate_no,
|
cert.certificate_no,
|
||||||
@@ -176,7 +177,7 @@ export const downloadCertificateImage = async (
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 4. Download All ZIP ───────────────────────────────────────────────────────
|
// Download All ZIP
|
||||||
export const downloadAllCertificatesZip = async (
|
export const downloadAllCertificatesZip = async (
|
||||||
certificates: Certificate[],
|
certificates: Certificate[],
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
@@ -189,7 +190,7 @@ export const downloadAllCertificatesZip = async (
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
certificates.map(async (cert, i) => {
|
certificates.map(async (cert, i) => {
|
||||||
const blob = await generateCertificate(
|
const blob = await generateCertificate(
|
||||||
resolveImageUrl(templateUrl),
|
resolveFileUrl(templateUrl),
|
||||||
cert.user.full_name,
|
cert.user.full_name,
|
||||||
cert.project.project_name,
|
cert.project.project_name,
|
||||||
cert.certificate_no,
|
cert.certificate_no,
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import api from "../../lib/api";
|
import { resolveFileUrl } from "./resolveFileFromUrl";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
export function resolveImageUrl(path: string): string {
|
|
||||||
if (!path) return "";
|
|
||||||
if (path.startsWith("data:")) return path;
|
|
||||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
const apiBaseURL = api.defaults.baseURL ?? "";
|
|
||||||
const origin = new URL(apiBaseURL).origin;
|
|
||||||
return `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadImage(src: string): Promise<HTMLImageElement> {
|
export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
|
if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
|
||||||
img.onload = () => resolve(img);
|
img.onload = () => resolve(img);
|
||||||
img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`));
|
img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`));
|
||||||
img.src = resolveImageUrl(src);
|
img.src = resolveFileUrl(src);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,14 +19,13 @@ export async function ensureFontsLoaded(): Promise<void> {
|
|||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const LAYOUT = {
|
const LAYOUT = {
|
||||||
internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" },
|
internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" },
|
||||||
internNameSmall: { x: 0.82, y: 0.889, size: 33, font: "Great Vibes", color: "#800000" },
|
internNameSmall: { x: 0.82, y: 0.889, size: 47, font: "Great Vibes", color: "#090909" },
|
||||||
projectName: { x: 0.815, y: 0.598, size: 46, font: "Grenze", color: "#800000" },
|
projectName: { x: 0.815, y: 0.598, size: 46, font: "Grenze", color: "#800000" },
|
||||||
duration: { x: 0.692, y: 0.645, size: 48, font: "Grenze", color: "#090909" },
|
duration: { x: 0.7, y: 0.645, size: 48, font: "Grenze", color: "#090909" },
|
||||||
certNo: { x: 0.6, y: 0.28, size: 50, font: "Grenze", color: "#090909" },
|
certNo: { x: 0.597, y: 0.305, size: 50, font: "Grenze", color: "#090909" },
|
||||||
certNoSmall: { x: 0.825, y: 0.91, size: 22, font: "Grenze", color: "#800000" },
|
certNoSmall: { x: 0.827, y: 0.92, size: 32, font: "Grenze", color: "#090909"},
|
||||||
qr: { x: 0.77, y: 0.715, size: 0.103 },
|
qr: { x: 0.77, y: 0.715, size: 0.103 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,7 +62,7 @@ export async function generateCertificate(
|
|||||||
text(LAYOUT.projectName, projectName);
|
text(LAYOUT.projectName, projectName);
|
||||||
text(LAYOUT.duration, ProjectDuration);
|
text(LAYOUT.duration, ProjectDuration);
|
||||||
text(LAYOUT.certNo, certificateNo);
|
text(LAYOUT.certNo, certificateNo);
|
||||||
text(LAYOUT.certNoSmall, certificateNo);
|
text(LAYOUT.certNoSmall, "No.2026/SPI3/" + certificateNo);
|
||||||
|
|
||||||
// QR code — nanti akan diganti ke alamat domain
|
// QR code — nanti akan diganti ke alamat domain
|
||||||
const qrSize = Math.round(canvas.width * LAYOUT.qr.size);
|
const qrSize = Math.round(canvas.width * LAYOUT.qr.size);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import {API_BASE_URL} from "../../lib/api";
|
||||||
|
|
||||||
|
|
||||||
|
export function resolveFileUrl(path?: string | null): string {
|
||||||
|
if (!path) return "";
|
||||||
|
if (/^(https?:|data:|blob:)/i.test(path)) return path;
|
||||||
|
return `${API_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// Ubah message backend (string ATAU object field-errors) jadi satu string aman
|
|
||||||
export function resolveErrorMessage(message: unknown, fallback = "Silakan coba lagi."): string {
|
|
||||||
if (typeof message === "string") return message;
|
|
||||||
if (message && typeof message === "object") {
|
|
||||||
// ambil semua value error, gabung: { end_date: "...", start_date: "..." } -> "... \n ..."
|
|
||||||
return Object.values(message as Record<string, unknown>)
|
|
||||||
.flat()
|
|
||||||
.filter((v): v is string => typeof v === "string")
|
|
||||||
.join(" ") || fallback;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const TEMPLATE_PATH = "../../assets/template.png";
|
const TEMPLATE_PATH = "../../assets/template.png";
|
||||||
|
|
||||||
// ====== KOORDINAT (rasio terhadap ukuran template) ======
|
// LAYOUT
|
||||||
// fillText -> titik TENGAH teks (textAlign=center, textBaseline=middle)
|
|
||||||
// drawImage -> pojok KIRI-ATAS gambar
|
|
||||||
const LAYOUT = {
|
const LAYOUT = {
|
||||||
internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" },
|
internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" },
|
||||||
internNameSmall: { x: 0.82, y: 0.889, size: 33, font: "Great Vibes", color: "#800000" },
|
internNameSmall: { x: 0.82, y: 0.889, size: 33, font: "Great Vibes", color: "#800000" },
|
||||||
@@ -25,7 +23,6 @@
|
|||||||
qr: { x: 0.77, y: 0.715, size: 0.103 },
|
qr: { x: 0.77, y: 0.715, size: 0.103 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ====== DATA DUMMY ======
|
|
||||||
const DATA = {
|
const DATA = {
|
||||||
internName: "Jonathan Kristina",
|
internName: "Jonathan Kristina",
|
||||||
projectName: "Internify Proyek Humic",
|
projectName: "Internify Proyek Humic",
|
||||||
|
|||||||
@@ -109,12 +109,12 @@ export const getInitials = (fullName?: string | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Untuk resolve gambar (duplikat dari utils/...)
|
// Untuk resolve gambar (duplikat dari utils/...)
|
||||||
export const resolveFileUrl = (path?: string | null): string | null => {
|
// export const resolveFileUrl = (path?: string | null): string | null => {
|
||||||
if (!path) return null;
|
// if (!path) return null;
|
||||||
if (/^(https?:|data:|blob:)/i.test(path)) return path;
|
// if (/^(https?:|data:|blob:)/i.test(path)) return path;
|
||||||
const base = import.meta.env.VITE_API_BASE_URL as string;
|
// const base = import.meta.env.VITE_API_BASE_URL as string;
|
||||||
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
// return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
};
|
// };
|
||||||
|
|
||||||
// PATCH /auth-api/update-profile
|
// PATCH /auth-api/update-profile
|
||||||
export const useUpdateProfile = () => {
|
export const useUpdateProfile = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user