Standards for GRC certificate inventory management, form field patterns, pagination, and table layouts for local-city-places codebase
This skill provides comprehensive guidance for working with the local-city-places codebase, covering GRC certificate inventory management, form field patterns, server-side pagination, and table layout standards.
When working with this codebase, follow these guidelines in order:
**Before modifying any GRC-related code**, understand the critical two-table flow:
**Tables:**
**Flow:**
1. Merchant purchases GRCs → Creates `grcPurchases` record with `paymentStatus: "pending"`
2. Admin approves payment → Updates `grcPurchases.paymentStatus` to `"confirmed"` (does NOT create grcs records)
3. Merchant issues GRC to customer → Creates `grcs` record linked to customer, inventory decreases
**Inventory Calculation:**
```typescript
available = sum(grcPurchases.quantity where confirmed) - count(grcs)
```
**Critical Rule:** The `grcs` table should ONLY contain certificates issued to customers. Never pre-create GRC records at approval time - they are created on-demand when a merchant issues to a customer.
**Label Spacing:**
**Required Fields:**
**Structure:**
```tsx
<div>
<Label htmlFor="email">Email *</Label>
<Input id="email" type="email" placeholder="..." />
<p className="text-xs text-muted-foreground mt-1">Helper text</p>
</div>
```
**City/State Fields:**
```tsx
const [cityState, setCityState] = useState("");
function parseCityState(value: string): { city: string; state: string } {
const parts = value.split(",").map(p => p.trim());
if (parts.length >= 2) {
const state = parts[parts.length - 1].toUpperCase().slice(0, 2);
const city = parts.slice(0, -1).join(", ");
return { city, state };
}
return { city: value.trim(), state: "" };
}
// UI
<Input id="cityState" placeholder="City, State" value={cityState} onChange={(e) => setCityState(e.target.value)} />
```
**Phone Number Fields:**
```tsx
import { formatPhoneNumber, stripPhoneNumber } from "@/lib/utils";
// On input
<Input type="tel" value={phone} onChange={(e) => setPhone(formatPhoneNumber(e.target.value))} placeholder="(425) 451-8599" />
// On submit
phone: stripPhoneNumber(phone) || null
```
Reserve space for asynchronously loaded content:
```tsx
// Use fixed min-height to prevent shift
<div className="flex gap-2 min-h-[32px] items-center">
{isLoading ? "Loading..." : items.map(...)}
</div>
```
**Common patterns:**
**API Endpoints:**
Support these query params:
Response format:
```json
{
"data": [...],
"pagination": {
"total": 150,
"page": 1,
"limit": 20,
"totalPages": 8
}
}
```
**Implementation (Drizzle):**
```typescript
const page = Math.max(1, parseInt(searchParams.get("page") || "1"));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "20")));
const offset = (page - 1) * limit;
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(table).where(whereClause);
const totalPages = Math.ceil(count / limit);
const data = await db.select().from(table).where(whereClause).limit(limit).offset(offset);
return { data, pagination: { total: count, page, limit, totalPages } };
```
**Frontend:**
```tsx
import { Pagination } from "@/components/ui/pagination";
<Pagination page={page} totalPages={totalPages} total={total} limit={20} onPageChange={setPage} disabled={isLoading} />
```
**Key patterns:**
**Page Structure Order:**
1. PageHeader (title + description)
2. Stats Row (2x2 mobile, 4-col desktop)
3. Search Input
4. Filter Tabs (+ Refresh button right-aligned)
5. Table/Cards
6. Pagination
**Responsive Pattern:**
Desktop (md+) uses `table-fixed`, mobile uses cards:
```tsx
{/* Mobile */}
<div className="md:hidden divide-y divide-border">
{items.map(item => <MobileCard key={item.id} />)}
</div>
{/* Desktop */}
<table className="w-full hidden md:table table-fixed">
<colgroup>
<col className="w-[25%]" />
<col className="w-[15%]" />
</colgroup>
<thead className="bg-muted/50 border-b">...</thead>
<tbody className="divide-y divide-border">...</tbody>
</table>
```
**Loading Behavior:**
**Stats Cards (Compact):**
```tsx
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-4 mb-6">
<div className="bg-card border rounded-lg p-3 sm:p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Icon className="w-4 h-4" />
<span className="text-xs sm:text-sm">Label</span>
</div>
<div className="text-lg sm:text-2xl font-bold">{count}</div>
<div className="text-xs sm:text-sm text-muted-foreground">{subtext}</div>
</div>
</div>
```
**State Management:**
```tsx
const [items, setItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState("pending");
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [page, setPage] = useState(1);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => { setDebouncedSearch(searchQuery); setPage(1); }, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
// Filter change resets page
const handleFilterChange = (newFilter) => { setFilter(newFilter); setPage(1); };
// Fetch with dependencies
const fetchData = useCallback(async () => {
setIsLoading(true);
const params = new URLSearchParams({ page: page.toString(), limit: "20" });
if (filter !== "all") params.set("status", filter);
if (debouncedSearch) params.set("search", debouncedSearch);
const res = await fetch(`/api/endpoint?${params}`);
const data = await res.json();
setItems(data.items);
setIsLoading(false);
}, [page, filter, debouncedSearch]);
useEffect(() => { fetchData(); }, [fetchData]);
```
**Vercel Environment Variables:**
Use `printf` instead of `echo` to avoid trailing newline:
```bash
printf "my-api-key" | vercel env add VAR_NAME production
```
**Authentication:**
JWT tokens in cookies (30-day expiry). Required env var: `JWT_SECRET`
**Example 1: Implementing GRC Purchase Approval**
When admin approves a GRC purchase:
**Example 2: Creating a Paginated Table**
Follow the 6-part page structure, use server-side pagination with debounced search, responsive table/cards pattern, and proper loading states.
**Example 3: Adding City/State Field to Form**
Use single combined input, parse on submit with `parseCityState()`, store separately in database, combine when loading from API.
Leave a review
No reviews yet. Be the first to review this skill!
# Download SKILL.md from killerskills.ai/api/skills/grc-data-model-and-form-standards/raw