diff --git a/components/shared/code-badge.tsx b/components/shared/code-badge.tsx
new file mode 100644
index 0000000..126dbeb
--- /dev/null
+++ b/components/shared/code-badge.tsx
@@ -0,0 +1,20 @@
+import { cn } from '@/lib/utils'
+
+interface CodeBadgeProps {
+ code: string | null | undefined
+ className?: string
+}
+
+export function CodeBadge({ code, className }: CodeBadgeProps) {
+ if (!code) return null
+ return (
+
+ {code}
+
+ )
+}
diff --git a/lib/code.ts b/lib/code.ts
new file mode 100644
index 0000000..6f1fb8c
--- /dev/null
+++ b/lib/code.ts
@@ -0,0 +1,54 @@
+import { prisma } from '@/lib/prisma'
+
+const STORY_AUTO_RE = /^ST-(\d+)$/
+const PBI_AUTO_RE = /^PBI-(\d+)$/
+
+const VALID_CODE_RE = /^[A-Za-z0-9._-]+$/
+
+export const MAX_CODE_LENGTH = 30
+
+export function isValidCode(code: string): boolean {
+ return code.length > 0 && code.length <= MAX_CODE_LENGTH && VALID_CODE_RE.test(code)
+}
+
+export function normalizeCode(input: string | null | undefined): string | null {
+ if (input == null) return null
+ const trimmed = input.trim()
+ return trimmed === '' ? null : trimmed
+}
+
+function nextSequential(existing: (string | null)[], pattern: RegExp): number {
+ let max = 0
+ for (const c of existing) {
+ if (!c) continue
+ const m = c.match(pattern)
+ if (m) {
+ const n = Number.parseInt(m[1], 10)
+ if (!Number.isNaN(n) && n > max) max = n
+ }
+ }
+ return max + 1
+}
+
+export async function generateNextStoryCode(productId: string): Promise {
+ const stories = await prisma.story.findMany({
+ where: { product_id: productId },
+ select: { code: true },
+ })
+ const next = nextSequential(stories.map((s) => s.code), STORY_AUTO_RE)
+ return `ST-${String(next).padStart(3, '0')}`
+}
+
+export async function generateNextPbiCode(productId: string): Promise {
+ const pbis = await prisma.pbi.findMany({
+ where: { product_id: productId },
+ select: { code: true },
+ })
+ const next = nextSequential(pbis.map((p) => p.code), PBI_AUTO_RE)
+ return `PBI-${next}`
+}
+
+export function deriveTaskCode(storyCode: string | null, indexOneBased: number): string | null {
+ if (!storyCode) return null
+ return `${storyCode}.${indexOneBased}`
+}