Add evidences list below each task
Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
@@ -21,6 +21,13 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
File as FileGeneric,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -42,7 +49,10 @@ import type { ControlOverviewPageQuery as ControlOverviewPageQueryType } from ".
|
|||||||
import type { ControlOverviewPageUpdateTaskStateMutation as ControlOverviewPageUpdateTaskStateMutationType } from "./__generated__/ControlOverviewPageUpdateTaskStateMutation.graphql";
|
import type { ControlOverviewPageUpdateTaskStateMutation as ControlOverviewPageUpdateTaskStateMutationType } from "./__generated__/ControlOverviewPageUpdateTaskStateMutation.graphql";
|
||||||
import type { ControlOverviewPageCreateTaskMutation as ControlOverviewPageCreateTaskMutationType } from "./__generated__/ControlOverviewPageCreateTaskMutation.graphql";
|
import type { ControlOverviewPageCreateTaskMutation as ControlOverviewPageCreateTaskMutationType } from "./__generated__/ControlOverviewPageCreateTaskMutation.graphql";
|
||||||
import type { ControlOverviewPageDeleteTaskMutation as ControlOverviewPageDeleteTaskMutationType } from "./__generated__/ControlOverviewPageDeleteTaskMutation.graphql";
|
import type { ControlOverviewPageDeleteTaskMutation as ControlOverviewPageDeleteTaskMutationType } from "./__generated__/ControlOverviewPageDeleteTaskMutation.graphql";
|
||||||
import type { ControlOverviewPageUploadEvidenceMutation as ControlOverviewPageUploadEvidenceMutationType } from "./__generated__/ControlOverviewPageUploadEvidenceMutation.graphql";
|
import type {
|
||||||
|
ControlOverviewPageUploadEvidenceMutation as ControlOverviewPageUploadEvidenceMutationType,
|
||||||
|
EvidenceState,
|
||||||
|
} from "./__generated__/ControlOverviewPageUploadEvidenceMutation.graphql";
|
||||||
|
|
||||||
const controlOverviewPageQuery = graphql`
|
const controlOverviewPageQuery = graphql`
|
||||||
query ControlOverviewPageQuery($controlId: ID!) {
|
query ControlOverviewPageQuery($controlId: ID!) {
|
||||||
@@ -60,6 +70,18 @@ const controlOverviewPageQuery = graphql`
|
|||||||
name
|
name
|
||||||
description
|
description
|
||||||
state
|
state
|
||||||
|
evidences(first: 10) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
fileUrl
|
||||||
|
mimeType
|
||||||
|
size
|
||||||
|
state
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +152,29 @@ const uploadEvidenceMutation = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Use the Relay-generated types with extensions for fields that might not be in the generated types yet
|
||||||
|
type EvidenceNode = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fileUrl: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
state: EvidenceState;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskNode = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
state: string;
|
||||||
|
evidences?: {
|
||||||
|
edges: Array<{
|
||||||
|
node: EvidenceNode | null;
|
||||||
|
} | null>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
function ControlOverviewPageContent({
|
function ControlOverviewPageContent({
|
||||||
queryRef,
|
queryRef,
|
||||||
}: {
|
}: {
|
||||||
@@ -155,7 +200,8 @@ function ControlOverviewPageContent({
|
|||||||
uploadEvidenceMutation
|
uploadEvidenceMutation
|
||||||
);
|
);
|
||||||
const control = data.control;
|
const control = data.control;
|
||||||
const tasks = control?.tasks?.edges.map((edge) => edge?.node) ?? [];
|
const tasks =
|
||||||
|
control?.tasks?.edges.map((edge) => edge?.node as TaskNode) ?? [];
|
||||||
|
|
||||||
const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false);
|
const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false);
|
||||||
const [newTaskName, setNewTaskName] = useState("");
|
const [newTaskName, setNewTaskName] = useState("");
|
||||||
@@ -181,6 +227,11 @@ function ControlOverviewPageContent({
|
|||||||
const [uploadingTaskId, setUploadingTaskId] = useState<string | null>(null);
|
const [uploadingTaskId, setUploadingTaskId] = useState<string | null>(null);
|
||||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||||
|
|
||||||
|
// State to track which task's evidence list is expanded
|
||||||
|
const [expandedEvidenceTaskId, setExpandedEvidenceTaskId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
// Get the connection ID for Relay
|
// Get the connection ID for Relay
|
||||||
const connectionId = useMemo(() => {
|
const connectionId = useMemo(() => {
|
||||||
// The connection key is defined in the GraphQL query as "ControlOverviewPage_tasks"
|
// The connection key is defined in the GraphQL query as "ControlOverviewPage_tasks"
|
||||||
@@ -458,6 +509,49 @@ function ControlOverviewPageContent({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to format file size
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to format date
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to toggle evidence list expansion
|
||||||
|
const toggleEvidenceList = (taskId: string) => {
|
||||||
|
if (expandedEvidenceTaskId === taskId) {
|
||||||
|
setExpandedEvidenceTaskId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedEvidenceTaskId(taskId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to get file icon based on mime type
|
||||||
|
const getFileIcon = (mimeType: string) => {
|
||||||
|
if (mimeType.startsWith("image/")) {
|
||||||
|
return <Image className="w-4 h-4 text-blue-500" />;
|
||||||
|
} else if (mimeType.includes("pdf")) {
|
||||||
|
return <FileText className="w-4 h-4 text-red-500" />;
|
||||||
|
} else if (mimeType.includes("word") || mimeType.includes("document")) {
|
||||||
|
return <FileText className="w-4 h-4 text-blue-600" />;
|
||||||
|
} else if (mimeType.includes("excel") || mimeType.includes("spreadsheet")) {
|
||||||
|
return <FileText className="w-4 h-4 text-green-600" />;
|
||||||
|
} else {
|
||||||
|
return <FileGeneric className="w-4 h-4 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -578,13 +672,16 @@ function ControlOverviewPageContent({
|
|||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<div
|
<div
|
||||||
key={task?.id}
|
key={task?.id}
|
||||||
|
className="rounded-md overflow-hidden border border-gray-200"
|
||||||
|
>
|
||||||
|
<div
|
||||||
className={`flex items-center gap-3 py-4 px-2 hover:bg-gray-50 group relative transition-all duration-200 ${
|
className={`flex items-center gap-3 py-4 px-2 hover:bg-gray-50 group relative transition-all duration-200 ${
|
||||||
isDraggingFile && draggedOverTaskId !== task?.id
|
isDraggingFile && draggedOverTaskId !== task?.id
|
||||||
? "border border-dashed border-blue-300 rounded-md bg-blue-50 bg-opacity-30"
|
? "border-dashed border-blue-300 bg-blue-50 bg-opacity-30"
|
||||||
: ""
|
: ""
|
||||||
} ${
|
} ${
|
||||||
draggedOverTaskId === task?.id
|
draggedOverTaskId === task?.id
|
||||||
? "bg-blue-50 border-2 border-blue-400 shadow-md rounded-md"
|
? "bg-blue-50 border-2 border-blue-400 shadow-md"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
onDragOver={(e) => task?.id && handleDragOver(e, task.id)}
|
onDragOver={(e) => task?.id && handleDragOver(e, task.id)}
|
||||||
@@ -695,6 +792,91 @@ function ControlOverviewPageContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Evidence section */}
|
||||||
|
{task?.evidences?.edges && task.evidences.edges.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="bg-gray-50 border-t border-gray-200 px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={() => task.id && toggleEvidenceList(task.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileIcon className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
{task.evidences.edges.length}{" "}
|
||||||
|
{task.evidences.edges.length === 1
|
||||||
|
? "Evidence"
|
||||||
|
: "Evidences"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expandedEvidenceTaskId === task.id ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedEvidenceTaskId === task.id && (
|
||||||
|
<div className="bg-white border-t border-gray-200 p-3 space-y-2">
|
||||||
|
{task.evidences.edges.map(
|
||||||
|
(edge: { node: EvidenceNode | null } | null) => {
|
||||||
|
const evidence = edge?.node;
|
||||||
|
if (!evidence) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={evidence.id}
|
||||||
|
className="flex items-center justify-between p-2 rounded border border-gray-200 hover:border-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getFileIcon(evidence.mimeType)}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">
|
||||||
|
{evidence.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
{formatFileSize(evidence.size)}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{formatDate(evidence.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{evidence.mimeType.startsWith("image/") && (
|
||||||
|
<a
|
||||||
|
href={evidence.fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1 rounded-full hover:bg-gray-100"
|
||||||
|
title="Preview"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-gray-600" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={evidence.fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1 rounded-full hover:bg-gray-100"
|
||||||
|
title="Download"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-gray-600" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{tasks.length === 0 && (
|
{tasks.length === 0 && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<297a88bb87d59c4a4ed2f8dce93667c6>>
|
* @generated SignedSource<<ba62806712d6eae0a97e09a9db5c5841>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { ConcreteRequest } from 'relay-runtime';
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
|
export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
|
||||||
|
export type EvidenceState = "EXPIRED" | "INVALID" | "VALID";
|
||||||
export type TaskState = "DONE" | "TODO";
|
export type TaskState = "DONE" | "TODO";
|
||||||
export type ControlOverviewPageQuery$variables = {
|
export type ControlOverviewPageQuery$variables = {
|
||||||
controlId: string;
|
controlId: string;
|
||||||
@@ -25,6 +26,18 @@ export type ControlOverviewPageQuery$data = {
|
|||||||
readonly edges: ReadonlyArray<{
|
readonly edges: ReadonlyArray<{
|
||||||
readonly node: {
|
readonly node: {
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
|
readonly evidences: {
|
||||||
|
readonly edges: ReadonlyArray<{
|
||||||
|
readonly node: {
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly fileUrl: string;
|
||||||
|
readonly id: string;
|
||||||
|
readonly mimeType: string;
|
||||||
|
readonly size: number;
|
||||||
|
readonly state: EvidenceState;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly state: TaskState;
|
readonly state: TaskState;
|
||||||
@@ -116,6 +129,75 @@ v8 = [
|
|||||||
(v3/*: any*/),
|
(v3/*: any*/),
|
||||||
(v4/*: any*/),
|
(v4/*: any*/),
|
||||||
(v5/*: any*/),
|
(v5/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"kind": "Literal",
|
||||||
|
"name": "first",
|
||||||
|
"value": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concreteType": "EvidenceConnection",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "evidences",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "EvidenceEdge",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "edges",
|
||||||
|
"plural": true,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "Evidence",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "node",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
(v2/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "fileUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "mimeType",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "size",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
(v5/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "createdAt",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": "evidences(first:10)"
|
||||||
|
},
|
||||||
(v7/*: any*/)
|
(v7/*: any*/)
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
@@ -259,7 +341,7 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "a13eb28fcbe162deaff92653c19cc22a",
|
"cacheID": "ae3ac6c53719fa3d9c1953ff40c75005",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"connection": [
|
"connection": [
|
||||||
@@ -276,11 +358,11 @@ return {
|
|||||||
},
|
},
|
||||||
"name": "ControlOverviewPageQuery",
|
"name": "ControlOverviewPageQuery",
|
||||||
"operationKind": "query",
|
"operationKind": "query",
|
||||||
"text": "query ControlOverviewPageQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n name\n description\n state\n category\n tasks(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
|
"text": "query ControlOverviewPageQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n name\n description\n state\n category\n tasks(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n evidences(first: 10) {\n edges {\n node {\n id\n fileUrl\n mimeType\n size\n state\n createdAt\n }\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "dc8c7670eafe558966932c3fb4d45cd8";
|
(node as any).hash = "ad8c4a4980229230ca295c4cb9a2c836";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<09af19b5a7657e04606a42962db9db2c>>
|
* @generated SignedSource<<f1cdd20d14ca9fefe868019e5aa43741>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -23,7 +23,7 @@ export type ControlOverviewPageUploadEvidenceMutation$data = {
|
|||||||
readonly uploadEvidence: {
|
readonly uploadEvidence: {
|
||||||
readonly evidenceEdge: {
|
readonly evidenceEdge: {
|
||||||
readonly node: {
|
readonly node: {
|
||||||
readonly createdAt: any;
|
readonly createdAt: string;
|
||||||
readonly fileUrl: string;
|
readonly fileUrl: string;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly mimeType: string;
|
readonly mimeType: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user