diff --git a/apps/db/supabase/migrations/20260424111249_add_qr_code.sql b/apps/db/supabase/migrations/20260424111249_add_qr_code.sql new file mode 100644 index 0000000000..701f028022 --- /dev/null +++ b/apps/db/supabase/migrations/20260424111249_add_qr_code.sql @@ -0,0 +1,128 @@ +create table "public"."qr_code" ( + "id" uuid not null default gen_random_uuid(), + "short_code" text not null, + "target_url" text, + "qr_type" character varying, + "design_settings" json, + "created_at" timestamp without time zone, + "scan_count" bigint, + "user_id" uuid default gen_random_uuid() +); + + +alter table "public"."qr_code" enable row level security; + + + create table "public"."scans" ( + "id" uuid not null default gen_random_uuid(), + "qr_id" uuid not null default auth.uid(), + "scanned_at" timestamp without time zone, + "device_type" character varying, + "country" character varying, + "ip_address" text + ); + + +alter table "public"."scans" enable row level security; + +CREATE UNIQUE INDEX qr_code_pkey ON public.qr_code USING btree (id); + +CREATE UNIQUE INDEX scans_pkey ON public.scans USING btree (id); + +alter table "public"."qr_code" add constraint "qr_code_pkey" PRIMARY KEY using index "qr_code_pkey"; + +alter table "public"."scans" add constraint "scans_pkey" PRIMARY KEY using index "scans_pkey"; + +alter table "public"."qr_code" add constraint "qr_code_user_id_fkey" FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."qr_code" validate constraint "qr_code_user_id_fkey"; + +alter table "public"."scans" add constraint "scans_qr_id_fkey" FOREIGN KEY (qr_id) REFERENCES public.qr_code(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."scans" validate constraint "scans_qr_id_fkey"; + +grant delete on table "public"."qr_code" to "anon"; + +grant insert on table "public"."qr_code" to "anon"; + +grant references on table "public"."qr_code" to "anon"; + +grant select on table "public"."qr_code" to "anon"; + +grant trigger on table "public"."qr_code" to "anon"; + +grant truncate on table "public"."qr_code" to "anon"; + +grant update on table "public"."qr_code" to "anon"; + +grant delete on table "public"."qr_code" to "authenticated"; + +grant insert on table "public"."qr_code" to "authenticated"; + +grant references on table "public"."qr_code" to "authenticated"; + +grant select on table "public"."qr_code" to "authenticated"; + +grant trigger on table "public"."qr_code" to "authenticated"; + +grant truncate on table "public"."qr_code" to "authenticated"; + +grant update on table "public"."qr_code" to "authenticated"; + +grant delete on table "public"."qr_code" to "service_role"; + +grant insert on table "public"."qr_code" to "service_role"; + +grant references on table "public"."qr_code" to "service_role"; + +grant select on table "public"."qr_code" to "service_role"; + +grant trigger on table "public"."qr_code" to "service_role"; + +grant truncate on table "public"."qr_code" to "service_role"; + +grant update on table "public"."qr_code" to "service_role"; + +grant delete on table "public"."scans" to "anon"; + +grant insert on table "public"."scans" to "anon"; + +grant references on table "public"."scans" to "anon"; + +grant select on table "public"."scans" to "anon"; + +grant trigger on table "public"."scans" to "anon"; + +grant truncate on table "public"."scans" to "anon"; + +grant update on table "public"."scans" to "anon"; + +grant delete on table "public"."scans" to "authenticated"; + +grant insert on table "public"."scans" to "authenticated"; + +grant references on table "public"."scans" to "authenticated"; + +grant select on table "public"."scans" to "authenticated"; + +grant trigger on table "public"."scans" to "authenticated"; + +grant truncate on table "public"."scans" to "authenticated"; + +grant update on table "public"."scans" to "authenticated"; + +grant delete on table "public"."scans" to "service_role"; + +grant insert on table "public"."scans" to "service_role"; + +grant references on table "public"."scans" to "service_role"; + +grant select on table "public"."scans" to "service_role"; + +grant trigger on table "public"."scans" to "service_role"; + +grant truncate on table "public"."scans" to "service_role"; + +grant update on table "public"."scans" to "service_role"; + + diff --git a/apps/web/package.json b/apps/web/package.json index ec8e810c4f..e07eb2783f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -80,8 +80,10 @@ "octokit": "^5.0.5", "papaparse": "^5.5.3", "phaser": "^3.90.0", + "qr-code-styling": "^1.9.2", "qrcode.react": "^4.2.0", "react": "^19.2.6", + "react-color-pikr": "^1.1.2", "react-confetti": "^6.4.0", "react-dom": "^19.2.6", "react-intersection-observer": "^10.0.3", diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/finance/transactions/[transactionId]/objects.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/finance/transactions/[transactionId]/objects.tsx index 49082a10fd..e2f1aa0638 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/finance/transactions/[transactionId]/objects.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/finance/transactions/[transactionId]/objects.tsx @@ -1,15 +1,15 @@ 'use client'; -import { TransactionObjectRowActions } from './row-actions'; -import { joinPath } from '@/utils/path-helper'; -import { StorageObject } from '@ncthub/types/primitives/StorageObject'; +import type { StorageObject } from '@ncthub/types/primitives/StorageObject'; import { Button } from '@ncthub/ui/button'; import { FileText, LayoutGrid, LayoutList } from '@ncthub/ui/icons'; import { Separator } from '@ncthub/ui/separator'; -import { useTranslations } from 'next-intl'; import Image from 'next/image'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import { useState } from 'react'; +import { joinPath } from '@/utils/path-helper'; +import { TransactionObjectRowActions } from './row-actions'; export function DetailObjects({ wsId, @@ -25,7 +25,7 @@ export function DetailObjects({ return (
-
+
{t('invoices.files')}
+ )} + {onDismiss && ( + + )} +
+ )} +
+ {onDismiss && ( + + )} +
+
+ ); +} + +// Toast Component - For transient notifications +export interface QRErrorToastProps { + error: QRError; + autoDismiss?: boolean; + duration?: number; + onDismiss?: () => void; +} + +export function QRErrorToast({ + error, + autoDismiss = true, + duration = 5000, + onDismiss, +}: QRErrorToastProps) { + const [isVisible, setIsVisible] = useState(true); + + const errorConfig = + ERROR_TYPES[error.id as ErrorId] || + ERROR_TYPES['unexpected-application-error']; + const IconComponent = errorConfig.icon; + + useEffect(() => { + if (!autoDismiss || !isVisible) return; + + const timer = setTimeout(() => { + setIsVisible(false); + onDismiss?.(); + }, duration); + + return () => clearTimeout(timer); + }, [autoDismiss, duration, isVisible, onDismiss]); + + if (!isVisible) return null; + + const bgClasses = { + info: 'bg-blue-900 text-blue-50', + warning: 'bg-amber-900 text-amber-50', + error: 'bg-red-900 text-red-50', + }; + + return ( +
+
+ +
+

{error.title}

+

{error.description}

+ {error.actionLabel && error.onAction && ( + + )} +
+ +
+
+ ); +} + +// Modal Component - For blocking errors requiring user attention +export interface QRErrorModalProps { + error: QRError; + isOpen: boolean; + onClose?: () => void; + secondaryLabel?: string; + onSecondary?: () => void; +} + +export function QRErrorModal({ + error, + isOpen, + onClose, + secondaryLabel = 'Cancel', + onSecondary, +}: QRErrorModalProps) { + if (!isOpen) return null; + + const errorConfig = + ERROR_TYPES[error.id as ErrorId] || + ERROR_TYPES['unexpected-application-error']; + const IconComponent = errorConfig.icon; + + const iconBgClasses = { + info: 'bg-blue-100 dark:bg-blue-950/40', + warning: 'bg-amber-100 dark:bg-amber-950/40', + error: 'bg-red-100 dark:bg-red-950/40', + }; + + const iconClasses = { + info: 'text-blue-600 dark:text-blue-400', + warning: 'text-amber-600 dark:text-amber-400', + error: 'text-red-600 dark:text-red-400', + }; + + return ( + <> + {/* Backdrop */} +